Micron Document
NexusPi Git Node

Commit a90d9cb5b93961bba59bb0a386712bd117a92f7f


Parents : 39a5230
Author : James L <jrl290@gmail.com>
Date : 2026-05-10T15:40:23-04:00

v1.0.33 — mixed-path proof routing, harnesses, and beta release flow

Changes
Diff

diff --git a/FirewallConfig.h b/FirewallConfig.h
index 9166818..8bf5199 100755
--- a/FirewallConfig.h
+++ b/FirewallConfig.h
@@ -245,7 +245,8 @@ static void config_send_html() {
html += F(
"<h2>&#x1f4e1; Local TCP Server (optional)</h2>"
"<p class='note'>Run a TCP server on the same WiFi network so local devices can connect. "
- "Uses Gateway mode (forwards announces to and from local TCP clients).</p>"
+ "Uses Gateway mode (forwards announces to and from local TCP clients). "
+ "Connect endpoint clients only; disable Reticulum transport mode on Meshchat or other LAN clients.</p>"
"<label>Local TCP Server</label>"
"<select name='ap_tcp_en'>"
);

diff --git a/LEARNED_SO_FAR.md b/LEARNED_SO_FAR.md
new file mode 100644
index 0000000..73e82f6
--- /dev/null
+++ b/LEARNED_SO_FAR.md
@@ -0,0 +1,765 @@
+# Learned So Far
+
+## Current Hardware
+
+- Current attached standard-RNode devices are:
+ - Heltec V4.3-class board, serial `00:00:00:0b`, at `/dev/cu.usbmodem11101`
+ - Heltec V4.2-class board, serial `00:00:00:0a`, at `/dev/cu.usbmodem114401`
+- The V3 has been removed from the active test path.
+- The current two-board baseline is V4.2 RNode <-> V4.3 RNode, not V3-based.
+- RTNode does not expose KISS serial access in FIREWALL_MODE. Serial is useful for boot/runtime logs only.
+- Use PlatformIO (`pio`) for flashing RTNode changes. Do not rely on interactive terminal prompts.
+
+## Firmware/Test Baseline
+
+- Built target: `rtnode_heltec_v4`.
+- Flash to RTNode succeeded with `pio run -e rtnode_heltec_v4 -t upload --upload-port /dev/cu.usbmodem114401`.
+- Post-upload `rnodeconf` provisioning/hash steps failed to detect the device. This is expected with RTNode FIREWALL_MODE serial behavior and did not indicate a failed flash.
+- Monitor needed explicit inactive control lines to see boot output:
+ - `pio device monitor -p /dev/cu.usbmodem114401 -b 115200 --filter direct --dtr 0 --rts 0`
+- A diagnostics firmware build has now been flashed. It adds visible `LOG_VERBOSE` markers for link and proof decisions:
+ - `[LINK] XPORT`, `[LINK] FWD`, `[LINK] DROP`
+ - `[LRPROOF] IN`, `[LRPROOF] FWD`, `[LRPROOF] DROP ...`, `[LRPROOF] LOCAL-CHECK`
+ - `[PROOF] XPORT`, `[PROOF] DROP ...`, `[PROOF] LOCAL`
+- Diagnostics build and flash both succeeded. The same expected post-upload `rnodeconf` detect/hash failures occurred.
+
+## RTNode Boot State Observed
+
+- RTNode boots successfully.
+- Active LoRa channel from EEPROM:
+ - Frequency: `914875000`
+ - Bandwidth: `125000`
+ - Spreading factor: `10`
+ - Coding rate: `5`
+ - TX power: `28`
+- LittleFS mounted and `destination_table` read as `{}` after flash/test boot.
+- RNS transport starts and loads transport identity.
+- LoRa startup announce is queued and reaches actual TX start:
+ - `[LoRa] TX 167 bytes`
+ - `[LoRa] TXSTART 167 bytes`
+- Local TCP server starts on port `4242` and mDNS advertises `mynode.local`.
+- Boot check after flashing diagnostics also succeeded with the same channel settings and startup `TXSTART` behavior.
+
+## Hardware Test Results
+
+Command used:
+
+```sh
+.venv/bin/python -m pytest tests/lora_test.py \
+ --rtnode-port /dev/cu.usbmodem114401 \
+ --rnode-port /dev/cu.usbmodem11201 \
+ --lora-freq 914875000 \
+ --lora-bw 125000 \
+ --lora-sf 10 \
+ --lora-cr 5 \
+ --lora-txp 28 \
+ --rx-timeout 20 \
+ --announce-timeout 90 \
+ -v
+```
+
+Results:
+
+- PASS: RTNode alive/log output.
+- PASS: RNS active.
+- PASS: No error logs during quiet observation.
+- PASS: RTNode channel config matches test parameters.
+- PASS: RNode -> RTNode single receive test.
+- PASS: RNode -> RTNode multiple receive test.
+- FAIL: RTNode -> RNode transmit decode test.
+
+Important interpretation:
+
+- RNode -> RTNode passing means the RTNode receive path and shared channel parameters are good enough for over-the-air reception.
+- RTNode logs `TXSTART`, so RTNode queues and begins RF transmission.
+- The RNode probe did not decode the RTNode startup packet within the test window. That points at RTNode transmit-side framing, radio TX behavior, test/probe receive assumptions, or RNode promiscuous decode compatibility.
+
+## Current User-Reported Problem
+
+The practical failure is not just the startup TX probe test.
+
+Scenario:
+
+- A LoRa client sends an LXMF message through RTNode to a TCP client on the other side.
+- The TCP client actually receives the LXMF message.
+- The LoRa client does not receive or does not recognize the proof.
+- Messages sent from the TCP client back toward the LoRa client are not received.
+- Other functionality is currently unknown.
+
+Working hypothesis from the symptom:
+
+- LoRa -> RTNode -> TCP forwarding is at least partially working for LXMF data.
+- The broken direction is TCP/local-client -> RTNode -> LoRa, or the proof/link-return path is being dropped, malformed, filtered, routed to the wrong interface, or transmitted in a way the LoRa client cannot decode.
+- This aligns with the hardware test asymmetry: RTNode can receive LoRa packets, but RTNode-originated/forwarded LoRa transmissions are not confirmed as decoded by the RNode probe.
+
+## Code Areas Already Identified As Relevant
+
+- `RNode_Firmware.ino`
+ - `LoRaInterface::send_outgoing()` queues RNS packets for LoRa TX.
+ - `transmit(uint16_t size)` adds RNode LoRa framing and writes to the SX126x.
+ - FIREWALL_MODE adds RX raw-shift handling and verbose `[LoRa] RX/TX/TXSTART` diagnostics.
+- `lib/microReticulum/src/Transport.cpp`
+ - `Transport::outbound()` returns false when no OUT interface accepts a packet, causing `No interfaces could process the outbound packet`.
+ - `path_request()` and immediate PATH_RESPONSE logic are relevant to proof/link return behavior.
+ - FIREWALL_MODE boundary whitelists and local-client interface logic are relevant to TCP -> LoRa return traffic.
+- `tests/lora_test.py`
+ - Current hardware tests prove RTNode RX and expose the current RTNode TX decode failure.
+
+## Next Investigation Steps
+
+- Reproduce the actual LXMF/TCP proof failure with logs showing packet type/context/interface on both directions.
+- Determine why RTNode LoRa TX reaches `TXSTART` but is not decoded by the standard RNode.
+- Inspect low-level SX1262 TX parameters, FIFO/base-address state, packet header mode, CRC/IQ settings, and timing against upstream RNode firmware.
+
+## 2026-05-09 Radio TX Diagnostic Update
+
+- RTNode Heltec V4 auto-detects the front-end module as KCT8103L:
+ - `[Boundary] PA detect: model=KCT8103L`
+- Restored runtime KCT/GC FEM detection under `FIREWALL_MODE`; the earlier forced-GC1109 diagnostic was wrong for this physical board.
+- Removed the temporary 14 dBm cap and confirmed the full-power mapping:
+ - `[Boundary] TXP: requested=28 effective=28 modem=17 pa=2`
+- Added a TX completion diagnostic in `sx126x::endPacket()`:
+ - Startup announce and LoRa path-response transmissions both reported `[Boundary] TXDONE irq=0001 timeout=0 pa=2`.
+ - This means the SX1262 reports TX_DONE; RTNode is not just entering `transmit()` and hanging.
+- During a LoRa path request, the standard RNode reported strong interference around the RTNode response window (`-27 dBm`) but still did not decode a packet.
+ - This strongly suggests RF is being emitted, but the RNode cannot demodulate the RTNode transmission.
+- Added a non-invasive SX1262 TX profile diagnostic for the next flash:
+ - `[Boundary] SXPA ...` logs PA config, OCP, and SX1262 TX parameter bytes when TX power is set.
+ - `[Boundary] TXCFG ...` logs the active frequency, SF, BW code, CR code, LDRO, preamble, header mode, payload length, CRC, IQ mode, and modem TX power at each transmit.
+- First capture with this diagnostic found a real TX-power application bug:
+ - Boot showed `[Boundary] TXP: requested=28 effective=28 modem=17 pa=2`, but transmit showed `[Boundary] TXCFG ... txp=2` and only `[Boundary] SXPA ... tx=0202`.
+ - Cause: `FIREWALL_MODE` bypassed RNode provisioning and left `model = 0x00`; `Utilities.h::setTXPower()` calculates the mapped power but only applies it in model-specific branches such as `MODEL_C8`.
+ - Fixed by setting `model = MODEL_C8` during the Heltec V4 `FIREWALL_MODE` provisioning bypass before `startRadio()`.
+ - Verification after flash showed `[Boundary] SXPA ... tx=1102` and `[Boundary] TXCFG ... txp=17`, so the SX1262 now applies the mapped modem output power.
+ - A fresh promiscuous RNode sniff after this fix still decoded no RTNode startup packet, so TX power application was a real bug but not the complete demodulation failure.
+- Added another read-only PHY diagnostic for the next flash:
+ - `[Boundary] DIO2RF=1` confirms the SX1262 DIO2 RF-switch opcode is enabled.
+ - `[Boundary] SXFREQ ...` logs the exact RF frequency opcode bytes.
+ - `[Boundary] SXSYNC ...` reads back the SX1262 sync word registers after programming.
+- Tried disabling the SX1262 DIO2 RF-switch opcode only for Heltec V4 `FIREWALL_MODE`, while retaining manual KCT CSD/CTX control.
+ - Result: `[Boundary] DIO2RF=0 diagnostic`, valid TXCFG/TXDONE, but the RNode still decoded nothing.
+ - Reverted to documented `DIO2RF=1` behavior after the failed diagnostic.
+- Tested these KCT/PA variants with an armed promiscuous RNode sniffer during RTNode boot TX; all still decoded nothing:
+ - Correct KCT path, CSD high, CTX high for TX.
+ - Forced/inverted CTX low during TX.
+ - CSD low diagnostic.
+ - GPIO46/CPS forced high diagnostic.
+ - Full power (`28 dBm`) and very low diagnostic cap (`2 dBm`).
+ - SX126x IQ register write to `0x0736` re-enabled.
+ - Explicit inverted IQ packet parameter (`buf[5] = 0x01`).
+
+Current interpretation:
+
+- The remaining failure is most consistent with a LoRa modem parameter/PHY mismatch, not RNS transport, packet bytes, path/proof logic, or absence of RF TX.
+- Next cheap discriminator is explicit IQ inversion in packet params (`buf[5]`) and then deeper SX1262 TX/RX register/status inspection.
+
+## 2026-05-09 Proof Probe Findings
+
+Added `tests/proof_probe.py`, a noninteractive minimal Reticulum proof probe using local source imports:
+
+```sh
+PYTHONPATH=/Users/james/Offline/Reticulum/Reticulum-master \
+ /Users/james/Offline/Reticulum/RTNode-HeltecV4/.venv/bin/python \
+ /Users/james/Offline/Reticulum/RTNode-HeltecV4/tests/proof_probe.py ...
+```
+
+LoRa client -> TCP server probe:
+
+- TCP server announces destination `2fcd02e9...` through RTNode TCP.
+- RTNode receives/stores the TCP announce and repeatedly queues LoRa transmissions for it.
+- LoRa client sends a path request; RTNode logs:
+ - `[PATH] REQ dst=2fcd02e9 from=Interface[LoRaInterface]`
+ - `[PATH] RESP dst=2fcd02e9 hops=0 to=Interface[LoRaInterface]`
+ - `[LoRa] TXSTART 183 bytes`
+- LoRa client still fails with `CLIENT_FAIL no path after 75.0s`.
+
+TCP client -> LoRa server probe:
+
+- LoRa server announces destination `1607e2d5...` through the standard RNode.
+- RTNode receives/stores LoRa announce from `Interface[LoRaInterface]`.
+- TCP client learns path via RTNode, sends data, and RTNode logs TCP packet forwarded to LoRa:
+ - `[PKT] IN iface=Interface[LocalTcpInterface] ... dst=1607e2d5 tid=11a63924`
+ - `[LoRa] TX 131/147 bytes`
+ - `[LoRa] TXSTART 131/147 bytes`
+- LoRa server never logs `SERVER_RX`; TCP client times out waiting for proof.
+
+Interpretation:
+
+- The minimal proof failure reproduces without LXMF complexity.
+- Transport path/proof forwarding is no longer the primary suspect for this minimal repro.
+- Current failure is below or at the RTNode LoRa TX layer: RTNode-originated/forwarded LoRa frames produce `TXSTART` but are not decoded by the standard RNode.
+
+## 2026-05-09 Radio Diagnostics Tried
+
+## 2026-05-09 Three-Device Cross-Listen Discriminator
+
+- All three devices were attached simultaneously:
+ - V4 RTNode: `/dev/cu.usbmodem11101`
+ - V3 RTNode: `/dev/cu.usbserial-0001`
+ - Standard RNode probe: `/dev/cu.usbmodem11201`
+- Port mapping was confirmed from boot diagnostics:
+ - V3 reports `txp=22`, `pa=0`, `ocp=18`, `tx=1602`.
+ - V4 reports `txp=28`, `PA detect: model=KCT8103L`, `pa=2`, `ocp=28`, `tx=1102`.
+- Cross-listen results:
+ - V4 TX -> V3 listener: V4 logged one `[LoRa] TXSTART 167 bytes` and `TXDONE`; V3 logged `[LoRa] RX 167 bytes`, a valid LoRaInterface packet, valid announce, and path storage.
+ - V3 TX -> V4 listener: V3 logged `[LoRa] TXSTART 167 bytes` and `TXDONE`; V4 logged `[LoRa] RX 167 bytes`, a valid LoRaInterface packet, valid announce, and path storage.
+- Three-device side-by-side result while V4 transmitted:
+ - V4 source: `v4_txstart=1`.
+ - V3 listener: `v3_lora_rx=1` with valid announce/path storage.
+ - Standard RNode probe: `rnode_packets=0`.
+- Capturing every KISS frame from the standard RNode during the V4 TX window showed only `CMD_STAT_CHTM` telemetry/config frames and no `CMD_DATA`, `CMD_STAT_RSSI`, or `CMD_STAT_SNR` receive indication for the RTNode packet.
+- New interpretation:
+ - RTNode-originated SX1262 LoRa frames are valid enough for another RTNode/SX1262 receiver to demodulate and hand to RNS.
+ - The failure is now narrowed away from generic RTNode TX absence, V4-only KCT PA behavior, and RNS transport/path/proof logic.
+ - The remaining suspect is an interoperability mismatch between RTNode/SX1262 TX behavior and the current standard RNode probe's receive modem/firmware behavior, or a configuration/receive-side issue specific to that standard RNode probe.
+ - This explains the original symptom: RTNode sends the LoRa return/proof path, but the LoRa client behind the standard RNode never decodes it.
+
+## 2026-05-09 Heltec V3 A/B Test
+
+- Heltec V3 RTNode was attached at `/dev/cu.usbserial-0001` and flashed with target `rtnode_heltec_v3`.
+- The `FIREWALL_MODE` provisioning-bypass `model = MODEL_C8` fix was extended to Heltec V3 as well as V4, because V3 otherwise leaves `model = 0x00` and `setTXPower()` would calculate but not apply SX1262 TX power.
+- V3 boot/TX diagnostics after flash:
+ - `[Boundary] LoRa: freq=914875000 bw=125000 sf=10 cr=5 txp=22`
+ - `[Boundary] SXSYNC requested=1424 reg=1424`
+ - `[Boundary] DIO2RF=1`
+ - `[Boundary] SXFREQ hz=914875000 reg=392e0000`
+ - `[Boundary] TXP: requested=22 effective=22 modem=22 pa=0`
+ - `[Boundary] SXPA pa=04070001 ocp=18 tx=1602`
+ - `[Boundary] TXCFG ... txp=22`
+ - `[Boundary] TXDONE irq=0001 timeout=0 pa=0`
+- Hardware test against the standard RNode at `/dev/cu.usbmodem11201`:
+ - PASS: V3 RTNode alive/RNS/channel config.
+ - PASS: RNode -> V3 RTNode receive, including multiple packet receive test.
+ - FAIL: V3 RTNode -> RNode receive; V3 logs `[LoRa] TXSTART`, but the RNode receives no decoded packet.
+- Interpretation:
+ - The same asymmetric result occurs on V3 and V4: RTNode can receive LoRa from the standard RNode, but the standard RNode does not decode RTNode-originated LoRa TX.
+ - This weakens the earlier V4.3/KCT8103L-specific hypothesis and points toward common SX1262 TX setup/framing, common RTNode firmware behavior, or an unverified receive-side issue on the standard RNode probe.
+ - A stronger discriminator would require either the V4 and V3 attached simultaneously, or a second known-good standard RNode receiver, to determine whether RTNode-originated packets are undecodable by all receivers or only by the current standard RNode probe.
+
+- TX power cap diagnostic in `Utilities.h::setTXPower()`:
+ - EEPROM requested `txp=28`.
+ - Firmware capped effective output to `14` dBm for `BOARD_HELTEC32_V4` + `FIREWALL_MODE`.
+ - Boot log: `[Boundary] TXP: requested=28 effective=14 modem=1 pa=2` for auto-detected KCT8103L.
+ - Result: no improvement; LoRa client still did not learn the path.
+- Forced GC1109 FEM path diagnostic in `sx126x.cpp`:
+ - Boot log: `[Boundary] PA diagnostic: forcing GC1109 path` and `pa=1`.
+ - Result: no improvement; LoRa client still did not learn the path.
+- Disabled the fork's SX1262 IQ-register write after `SetPacketParams`:
+ - The fork wrote register `0x0736` after every packet-params update; upstream RNode firmware does not.
+ - Result: no improvement; LoRa client still did not learn the path.
+- Disabled the fork's SX1262 `optimizeModemSensitivity()` register write:
+ - The fork wrote register `0x0889` for non-500 kHz bandwidths; upstream RNode firmware leaves this function empty.
+ - Result: no improvement; LoRa client still did not learn the path.
+
+Current diagnostic firmware state:
+
+- RTNode is flashed with the restored diagnostic baseline, not the failed TX power cap or forced-GC1109 experiments.
+- Current baseline keeps normal TX power, runtime KCT8103L PA/FEM detection, normal IQ packet params, documented `DIO2RF=1`, and the `model = MODEL_C8` fix so `setTXPower()` actually applies the mapped SX1262 TX power.
+- Useful guarded diagnostics remain: DIO2RF, sync word, RF frequency opcode bytes, PA/TX parameter bytes, TXCFG, TXDONE, and LoRa RX/TX/TXSTART logs.
+- Post-upload PlatformIO `rnodeconf` provisioning/hash steps are disabled because RTNode FIREWALL_MODE serial is logs-only, not KISS, and these checks only add expected noise after a successful flash.
+
+2026-05-12 follow-up diagnostics:
+
+- Standard RNode probe identity:
+ - Port: `/dev/cu.usbmodem11201`
+ - Product: Wio Tracker L1 862-930 MHz
+ - Firmware: 1.85
+ - Modem: SX1262
+ - Max TX power: 22 dBm
+- Forcing the probe to explicit LoRa header mode with `CMD_IMPLICIT=0` did not make it decode RTNode-originated frames.
+- Reconfiguring the probe with a full radio restart (`RADIO_STATE_OFF`, channel config, `CMD_IMPLICIT=0`, `RADIO_STATE_ON`, promisc) also did not make it decode V3-originated frames.
+- Three-device discriminator after proper probe restart:
+ - V3 RTNode TX logged `[LoRa] TXSTART 167 bytes`, TXCFG explicit header, CRC on, IQ normal, TXDONE.
+ - V4 RTNode listener decoded the V3 announce and stored the path.
+ - Wio Tracker L1 probe emitted channel telemetry only and no KISS DATA frame.
+- Important test-helper lesson:
+ - Standard RNode config setters can run while the radio is already online and do not force continuous RX afterward.
+ - Hardware tests should force `RADIO_STATE_OFF` before channel setup, then `RADIO_STATE_ON` after setup, to avoid false-negative receive tests.
+- Current interpretation:
+ - The standard probe sees RF/channel activity during RTNode TX but does not produce a valid decoded packet.
+ - Because V3 and V4 RTNodes decode each other, the RTNode TX is not generally malformed, but it is still not accepted by the Wio Tracker L1 receiver.
+ - Next strong discriminator: flash a Heltec V3 with plain/non-FIREWALL RNode firmware and test whether the Wio can decode that upstream-style Heltec TX.
+
+## 2026-05-12 v1.0.29 A/B Test
+
+- Created/used a `v1.0.29` worktree at `/Users/james/Offline/Reticulum/RTNode-HeltecV4-v1029`.
+- Built and uploaded `v1.0.29` target `rtnode_heltec_v3` to `/dev/cu.usbserial-0001`.
+ - PlatformIO reported success; old `extra_script.py` still ran noisy `rnodeconf` post-upload checks afterward.
+ - Wio Tracker L1 probe was configured OFF -> channel params -> `CMD_IMPLICIT=0` -> ON -> promisc.
+ - Result: no KISS `CMD_DATA` frames decoded by the Wio during the old V3 boot/TX window.
+- Full-flashed exact mirrored release image `docs/firmware/v1.0.29/rtnode_heltec_v3_merged.bin` to `/dev/cu.usbserial-0001` at offset `0x0`.
+ - Hash verified, but after reset the device entered setup portal mode:
+ - `[Boundary] RTNode app marker missing — previous firmware was not RTNode or config is unclaimed`
+ - `[Boundary] Starting config portal to migrate settings into RTNode`
+ - `[Config] AP started: RTNode-Setup`
+ - Result: invalid as a LoRa decode test because normal RNS/LoRa did not start.
+- App-flashed exact mirrored release image `docs/firmware/v1.0.29/rtnode_heltec_v4.bin` to V4 at offset `0x10000`, preserving the V4 config/filesystem.
+ - V4 booted normally and loaded LoRa config from EEPROM.
+ - RNS transport started, TCP server listened on port `4242`, and counters showed one outbound packet (`pout: 1`).
+ - Wio Tracker L1 probe again saw only config/channel telemetry frames (`0x25`/`0x26`) and no KISS `CMD_DATA` frames during the old V4 boot/TX window.
+- Restored V4 to the current diagnostic firmware with `pio run -e rtnode_heltec_v4 -t upload --upload-port /dev/cu.usbmodem11101`; upload succeeded and current post-upload `rnodeconf` steps were skipped.
+- Restored V3 to the current diagnostic firmware with `pio run -e rtnode_heltec_v3 -t upload --upload-port /dev/cu.usbserial-0001`; upload succeeded and current post-upload `rnodeconf` steps were skipped.
+ - Follow-up reset/boot check showed V3 is not stuck in setup portal mode.
+ - V3 loaded LoRa config from EEPROM (`914875000`, `125000`, SF10, CR5, TXP22), started RNS, queued/transmitted a 167-byte LoRa startup packet, and decoded inbound LoRa frames during the check.
+- Interpretation:
+ - The configured V4 app-only A/B test is the cleanest old-release result so far: `v1.0.29` ran normally, transmitted at least one RNS packet, and the Wio still decoded no LoRa DATA.
+ - Therefore the Wio no-decode behavior is not yet proven to be a current-code regression from `v1.0.29`.
+ - The full-flashed V3 exact image cannot answer the question until it is configured out of setup portal mode.
+ - The V3 app/boot/partition images have been restored to current firmware after the full-flash test, and a boot check confirmed it starts normal RNS/LoRa operation again.
+
+## 2026-05-09 v1.0.28 RTNode-Only A/B Test
+
+- User requested testing an earlier release without using the standard RNode/Wio probe.
+- App-flashed `docs/firmware/v1.0.28/rtnode_heltec_v4.bin` to V4 at offset `0x10000`, preserving the V4 config/filesystem.
+- First reset attempt was invalid because serial was opened before `esptool.py run`, leaving the V4 in ESP32 download mode (`waiting for download`). Recovered with `esptool.py --chip esp32s3 --port /dev/cu.usbmodem11101 run` and reran with a safer reset/listen order.
+- Valid RTNode-only test setup:
+ - Current diagnostic V3 at `/dev/cu.usbserial-0001` was reset first and used as the listener.
+ - `v1.0.28` V4 at `/dev/cu.usbmodem11101` was reset as the source.
+ - No standard RNode/Wio probe was used.
+- Result:
+ - V4 `v1.0.28` booted normally, loaded LoRa config, started RNS and TCP, and counters showed `pout: 1`.
+ - V3 current diagnostic firmware decoded the V4 transmission:
+ - `[LoRa] RX 167 bytes`
+ - `[PKT] IN iface=Interface[LoRaInterface] sz=167 type=1 ctx=0 hdr=0 dstt=0 hops=1 dst=6e89720e tid=none`
+- Restored V4 to current diagnostic firmware with `pio run -e rtnode_heltec_v4 -t upload --upload-port /dev/cu.usbmodem11101`; upload succeeded and post-upload `rnodeconf` steps were skipped.
+- Interpretation:
+ - Earlier release `v1.0.28` can transmit a startup LoRa announce that another RTNode decodes.
+ - This RTNode-only test does not exercise the standard RNode/Wio decode path and therefore should not be used as evidence that the Wio interoperability problem is fixed.
+ - It does reinforce the current pattern: RTNode SX1262-to-RTNode SX1262 decode works, while standard Wio/RNode decode remains the failing interoperability path.
+
+## 2026-05-10 Standard RNode Baseline Attempt
+
+- User clarified that RTNode-to-RTNode success is not sufficient: RNode compatibility is the standard to follow.
+- Built/flashed the fork's non-FIREWALL `heltec_wifi_lora_32_V3` target to `/dev/cu.usbserial-0001`.
+ - Initial build failed because FIREWALL-only RTC node-hash cache symbols were referenced in the standard build.
+ - Patched the RTC node-hash cache block under `#ifdef FIREWALL_MODE`; standard V3 build/upload then succeeded.
+- Standard V3 initially accepted KISS channel config but kept reporting `CMD_RADIO_STATE=00`.
+ - Cause 1: missing standard RNode EEPROM provisioning. Fixed with `rnodeconf --rom --product c1 --model ca --hwrev 1 /dev/cu.usbserial-0001`.
+ - Cause 2: missing saved TNC config. Fixed with `rnodeconf --tnc --freq 914875000 --bw 125000 --txp 17 --sf 10 --cr 5 /dev/cu.usbserial-0001`.
+ - Cause 3: local standard firmware hash not stored after post-upload provisioning/hash hooks were disabled. Fixed by computing the appended ESP32 partition hash from `.pio/build/heltec_wifi_lora_32_V3/rtnode_heltec32v3.bin` and setting it with `rnodeconf --firmware-hash`.
+ - After those steps, the fork standard V3 reported `CMD_RADIO_STATE=01`.
+- The fork's standard target still did not exchange LoRa DATA with the Wio Tracker L1 in either direction, despite both sides reporting radio online.
+- Flashed stock/official RNode firmware `1.86` to the V3 with `rnodeconf --update /dev/cu.usbserial-0001` to remove fork build flags and local edits from the baseline.
+ - Stock V3 reported TNC mode, frequency `914.875 MHz`, bandwidth `125 KHz`, TX power `22 dBm` in `rnodeconf --info`.
+ - Stock V3 and Wio both reported `CMD_RADIO_STATE=01` during tests.
+ - Stock V3 <-> Wio still produced no KISS `CMD_DATA` in either direction.
+ - Retested with long payloads after noticing short arbitrary test payloads can be below RNode's queue minimum and create false negatives.
+ - Disabled interference avoidance on both devices with `rnodeconf --ia-disable`; no change.
+ - Tested with upstream `RNode.py` host interface rather than the local `tests/kiss_serial.py` helper; no packets arrived.
+- Sanity check: Wio still transmits real RF. With the same channel settings, the RTNode V4 at `/dev/cu.usbmodem11101` logged `[LoRa] RX ...` immediately after Wio `CMD_DATA` TX.
+- Restored V3 to current RTNode FIREWALL_MODE firmware with `pio run -e rtnode_heltec_v3 -t upload --upload-port /dev/cu.usbserial-0001`.
+ - Follow-up sanity check: restored V3 RTNode again decoded Wio TX and logged `[LoRa] RX ...`.
+- Current interpretation:
+ - The Heltec V3 hardware is not dead; it receives Wio packets in RTNode/FIREWALL_MODE.
+ - Stock/standard RNode firmware on this V3, even when provisioned and radio-online, did not receive from or transmit decodable packets to the Wio in these bench tests.
+ - This means a V3 standard-firmware baseline is not currently a reliable oracle for fixing RTNode-to-Wio TX.
+ - Wio-to-RTNode RX remains confirmed, while RTNode-to-Wio TX remains the standard-compatibility failure to solve.
+- Current hardware state after user request: V3 at `/dev/cu.usbserial-0001` was directly flashed back to official RNode `1.86` from the cached `rnode_firmware_heltec32v3` package and verified with `rnodeconf --info` as Heltec LoRa32 V3 high-band TNC on `914.875 MHz`, BW `125 KHz`, SF10, CR5, TXP22.
+- Follow-up user request: V3 official RNode was changed away from startup TNC operation with `rnodeconf --normal --bluetooth-on --wifi OFF /dev/cu.usbserial-0001`. Verification with `rnodeconf --info` reports `Device mode: Normal (host-controlled)`. Bluetooth was enabled and WiFi disabled; USB serial still exists for configuration, but the device is no longer configured as an auto-start USB TNC.
+- Pairing screen follow-up: user reported the screen blanks when entering Bluetooth pairing mode. Applied `rnodeconf --display 255 --timeout 0 /dev/cu.usbserial-0001` to set maximum display intensity and disable display blanking. Then started `rnodeconf --bluetooth-pair /dev/cu.usbserial-0001`, confirmed it reached pairing mode, and exited it cleanly because the user was not available to pair live.
+
+## 2026-05-10 Direct RTNode to Official RNode Test
+
+- Wio was removed from the test path after user reported the Wio can send but cannot receive.
+- Live devices for this test:
+ - V4 RTNode: `/dev/cu.usbmodem11101`
+ - V3 official RNode 1.86: `/dev/cu.usbserial-0001`
+- Verified V3 official RNode with `rnodeconf --info`; it reports Heltec LoRa32 V3 high-band, firmware `1.86`, SX1262, device mode `Normal (host-controlled)`.
+- Ran focused pytest hardware checks on channel `914875000 / 125000 / SF10 / CR5` with the V3 RNode configured at TXP22.
+- Result 1: official RNode -> RTNode receive path works. Single receive passed, and repeat receive passed `3/3` with RTNode logging `[LoRa] RX` for each packet.
+- Result 2: RTNode -> official RNode receive path does not work. RTNode logs `[LoRa] TX 167 bytes` and `[LoRa] TXSTART 167 bytes`, but the official RNode receives no KISS `CMD_DATA` packet.
+- Current direct-RNode interpretation: the failure no longer requires the Wio. RTNode can receive from official RNode, but official RNode does not decode RTNode-originated frames in the current setup.
+
+## 2026-05-10 Additional V4.2 Standard Probe
+
+- User connected another device identified as `/dev/cu.usbmodem11201` to rule out bad RX on multiple existing devices.
+- Initial state: it was running RTNode setup portal firmware and booted into `RTNode-Setup`; it did not answer `rnodeconf` as a standard RNode.
+- Flashed non-FIREWALL `heltec_wifi_lora_32_V4` firmware to `/dev/cu.usbmodem11201`; upload succeeded.
+- Bootstrapped EEPROM with `rnodeconf --rom --product c3 --model c8 --hwrev 1 /dev/cu.usbmodem11201`.
+- First KISS checks showed accepted config but radio stayed OFF. Boot log said `RNS is inoperable because hardware is not ready! Check firmware signature and eeprom provisioning`.
+- Computed the ESP32 app partition hash from `.pio/build/heltec_wifi_lora_32_V4/rtnode_heltec32v4.bin` and applied it with `rnodeconf --firmware-hash ... /dev/cu.usbmodem11201`.
+- After firmware hash provisioning, `rnodeconf --info` reports Heltec LoRa32 V4 high-band (`c3:c8:3f`), firmware `1.85`, signature validated, SX1262, Normal mode. KISS config now brings radio state to `ON`.
+- V4.2 probe test on `914875000 / 125000 / SF10 / CR5`:
+ - V4.2 standard RNode -> RTNode: PASS, including `3/3` at `14 dBm` and `3/3` at `2 dBm` after hash provisioning.
+ - RTNode -> V4.2 standard RNode: FAIL. RTNode logs `[LoRa] TX 167 bytes` and `[LoRa] TXSTART 167 bytes`, but the V4.2 standard RNode receives no KISS `CMD_DATA`.
+- Near-field note: devices are within about one meter. Standard probes can use `--lora-txp 2` for receive-path tests; RTNode persistent config still logs `txp=28`, so RTNode-originated near-field results should consider possible overload until RTNode TX power is lowered persistently or devices are separated/attenuated.
+
+## 2026-05-10 Fresh Official RNode Download Check
+
+- Tested the "maybe the flashed package is corrupted" hypothesis directly.
+- GitHub latest release for `markqvist/RNode_Firmware` is still `1.86`.
+- Fresh download checksum checks:
+ - `rnode_firmware_heltec32v3.zip` from GitHub `1.86` matched the cached `~/.config/rnodeconf/update/1.86/rnode_firmware_heltec32v3.zip` exactly.
+ - `rnode_firmware_heltec32v4pa.zip` from GitHub `1.85` matched the cached `~/.config/rnodeconf/update/1.85/rnode_firmware_heltec32v4pa.zip` exactly.
+- Official `1.86` release assets do include `rnode_firmware_heltec32v4pa.zip`, so the V4.2 probe was reflashed directly from a fresh GitHub-downloaded official `1.86` package, not from the local repo build.
+- After the direct official flash, the V4.2 firmware hash was updated from the freshly downloaded `rnode_firmware_heltec32v4pa.bin`, and `rnodeconf --info /dev/cu.usbmodem11201` reported:
+ - Firmware `1.86`
+ - Product `Heltec LoRa32 v4 850 - 950 MHz (c3:c8:3f)`
+ - Signature validated
+ - Normal mode
+- Retest after fresh official `1.86` flash:
+ - V4.2 standard RNode -> RTNode: PASS `3/3` at `14 dBm`.
+ - RTNode -> V4.2 standard RNode: FAIL unchanged. RTNode still logs `[LoRa] TXSTART 167 bytes`, but the freshly downloaded official `1.86` V4.2 receives no packet.
+- Current interpretation: the failure is not explained by a corrupted cached firmware package or by the V4.2 running a stale local standard build. The direct incompatibility reproduces with freshly downloaded official upstream RNode firmware.
+
+## 2026-05-10 Python Reticulum rnsd/rnprobe Check
+
+- Earlier direct RNode-to-RNode smoke tests used the local raw KISS helper in `tests/kiss_serial.py` and `CMD_DATA` frames, not Python Reticulum.
+- To test the user's requested Python path, the KCT8103L Heltec V4.3 board at `/dev/cu.usbmodem11101` was reflashed from the fresh official `1.86` `rnode_firmware_heltec32v4pa` package, provisioned as `c3:c8:3f`, set to Normal mode, and used as a standard RNode alongside the V4.2 at `/dev/cu.usbmodem11201`.
+- Created isolated `rnsd` configs under:
+ - `tests/rnsd_v43/config`
+ - `tests/rnsd_v42/config`
+- Important Python Reticulum startup quirk:
+ - `RNS.Interfaces.RNodeInterface.validateRadioState()` validates after only `0.25 s`.
+ - On these boards, the first `CMD_TXPOWER` echo can transiently report the previous value before settling to the requested value.
+ - Priming both radios to the target channel and `14 dBm` before starting `rnsd` avoided the startup mismatch and allowed both daemons to power up cleanly.
+- To avoid attaching to an unrelated local shared Reticulum instance on macOS, the configs were switched to:
+ - `share_instance = Yes`
+ - `shared_instance_type = tcp`
+ - unique `shared_instance_port` / `instance_control_port` values per config.
+- Valid Python-stack result:
+ - V4.3 `rnsd` came up on `/dev/cu.usbmodem11101` and exposed probe destination `rnstransport.probe ... :12d871251e253efbc9403bc568364219`.
+ - V4.2 `rnsd` came up on `/dev/cu.usbmodem11201` and exposed probe destination `rnstransport.probe ... :9e3b27a5e0e03720ac16623bc4549f43`.
+ - `rnprobe` from V4.2 config to the V4.3 probe destination: `Path request timed out`.
+ - `rnprobe` from V4.3 config to the V4.2 probe destination: `Path request timed out`.
+ - The live `rnsd` logs showed no subsequent path-request or receive activity during these probe attempts.
+- Current interpretation at that point: the direct V4.3 standard RNode <-> V4.2 standard RNode path appeared to fail when using Python Reticulum (`rnsd` + `rnprobe`), not only when using the raw KISS test helper.
+
+## 2026-05-10 User-Confirmed V4.2 <-> V4.3 Standard RNode Success
+
+- User has now successfully sent from Meshchat to Sideband and back using the V4.2 and V4.3, both running standard RNode firmware.
+- The V3 has been removed from the equation.
+- Current attached ports were rechecked after this update:
+ - `/dev/cu.usbmodem11101` reports serial `00:00:00:0b`
+ - `/dev/cu.usbmodem114401` reports serial `00:00:00:0a`
+- Both attached devices report official RNode firmware `1.86`, validated signatures, product `Heltec LoRa32 v4 850 - 950 MHz (c3:c8:3f)`, SX1262, and Normal mode.
+- This user-confirmed application-layer round trip supersedes the earlier assumption that the standard-RNode bench baseline itself was broken.
+- Updated interpretation:
+ - Standard RNode operation between the V4.2 and V4.3 is now confirmed at the application level.
+ - Earlier raw KISS and Python `rnsd`/`rnprobe` failures were therefore not a reliable representation of the current real-world standard-RNode baseline.
+ - With the V3 removed and V4.2 <-> V4.3 standard RNode confirmed working, the active interoperability question should be narrowed back toward RTNode versus the now-working standard-RNode pair when RTNode is reintroduced.
+
+## 2026-05-10 Independent Reticulum Proof Round Trip Confirmed
+
+- Independently verified the correct protocol path with `tests/proof_probe.py`, not just with raw KISS and not only from user-observed Meshchat/Sideband behavior.
+- Active standard-RNode ports during this verification:
+ - V4.3-side standard RNode: `/dev/cu.usbmodem11101`
+ - V4.2-side standard RNode: `/dev/cu.usbmodem114401`
+- Shared LoRa settings during the proof test:
+ - Frequency `914875000`
+ - Bandwidth `125000`
+ - SF `10`
+ - CR `5`
+ - TXP `14`
+
+Forward direction, V4.2 -> V4.3:
+
+- Started `proof_probe.py server --side lora` on the V4.3-side RNode at `/dev/cu.usbmodem11101` with work dir `tests/proof_probe_v43_server`.
+- Server destination hash: `539f534769778629b697b0b401327282`.
+- Started `proof_probe.py client --side lora` on the V4.2-side RNode at `/dev/cu.usbmodem114401` with work dir `tests/proof_probe_v42_client`.
+- Client result:
+ - `CLIENT_PATH_READY elapsed=4.6s`
+ - `CLIENT_SENT ... len=21`
+ - `CLIENT_DELIVERED rtt=4.245s`
+- Server result:
+ - answered the path request for the local destination
+ - `SERVER_RX side=lora len=21 ... from=RNodeInterface[RNode LoRa]`
+
+Reverse direction, V4.3 -> V4.2:
+
+- Started `proof_probe.py server --side lora` on the V4.2-side RNode at `/dev/cu.usbmodem114401` with work dir `tests/proof_probe_v42_server`.
+- Server destination hash: `ef78a46ee16a1e5a08e627f53ac27beb`.
+- Started `proof_probe.py client --side lora` on the V4.3-side RNode at `/dev/cu.usbmodem11101` with work dir `tests/proof_probe_v43_client`.
+- Client result:
+ - `CLIENT_PATH_READY elapsed=3.0s`
+ - `CLIENT_SENT ... len=21`
+ - `CLIENT_DELIVERED rtt=4.345s`
+- Server result:
+ - answered the path request for the local destination
+ - `SERVER_RX side=lora len=21 ... from=RNodeInterface[RNode LoRa]`
+
+- Current interpretation:
+ - Standard RNode V4.2 <-> V4.3 is now independently confirmed with full Reticulum delivery and proof return in both directions.
+ - This is a stronger confirmation than earlier raw KISS packet tests or the earlier `rnsd`/`rnprobe` path-discovery attempts.
+ - The current known-good reference protocol path is therefore the bidirectional proof round trip between the V4.2 and V4.3 standard-RNode pair.
+
+## 2026-05-10 Mixed Baseline Restored: RTNode + Standard RNode
+
+- User preference was to stop keeping both current boards on standard firmware and instead return one of the two now-proven-good boards to RTNode firmware.
+- Selected split:
+ - `/dev/cu.usbmodem11101` restored to RTNode firmware via `pio run -e rtnode_heltec_v4 -t upload --upload-port /dev/cu.usbmodem11101`
+ - `/dev/cu.usbmodem114401` kept as the standard-RNode control board
+- PlatformIO upload to `/dev/cu.usbmodem11101` completed cleanly:
+ - flash/write succeeded
+ - `Hash of data verified`
+ - `Hard resetting via RTS pin`
+ - `SUCCESS`
+- Post-upload note:
+ - `extra_script.py` still skips post-upload `rnodeconf` provisioning/hash steps, which is expected and appropriate for RTNode `FIREWALL_MODE`
+- Short boot/runtime capture from `/dev/cu.usbmodem11101` confirmed RTNode is running again:
+ - `[Boundary] Provisioning check bypassed, modem installed`
+ - `[Boundary] No LoRa config in EEPROM, using defaults`
+ - `[Boundary] LoRa: freq=914875000 bw=125000 sf=10 cr=5 txp=28`
+ - `Starting RNS...`
+ - `Transport mode is enabled`
+ - `[TcpIF] Server listening on port 4242`
+ - `[mDNS] STA up: mynode.local (_reticulum._tcp port 4242)`
+ - `[LoRa] TXSTART 167 bytes`
+- Control-board verification on `/dev/cu.usbmodem114401` confirmed it remains a standard RNode:
+ - firmware `1.86`
+ - product `Heltec LoRa32 v4 850 - 950 MHz (c3:c8:3f)`
+ - serial `00:00:00:0a`
+ - `Device mode: Normal (host-controlled)`
+- Current active baseline after this change:
+ - RTNode under test: `/dev/cu.usbmodem11101`
+ - standard-RNode control: `/dev/cu.usbmodem114401`
+- Updated interpretation:
+ - The two-board known-good standard baseline has now served its purpose.
+ - The active next debugging target should be RTNode versus the still-known-good standard RNode on the same V4-class hardware pair.
+
+## 2026-05-10 Mixed Proof Test: LoRa Client -> RTNode TCP Server PASS
+
+- Exact RTNode firmware baseline used for this test:
+ - flashed from the current local `RTNode-HeltecV4` working tree, not from an older mirrored release image
+ - local repo identity at flash/test time: `v1.0.32-1-g39a5230-dirty`
+ - internal firmware version macros in `Config.h` remain `MAJ_VERS 0x01` / `MIN_VERS 0x55` (reported protocol version `1.85`)
+- Active devices during this test:
+ - RTNode under test on `/dev/cu.usbmodem11101`
+ - standard RNode control on `/dev/cu.usbmodem114401`
+- Test direction run:
+ - TCP-side proof server connected through RTNode local TCP on `mynode.local:4242`
+ - standard-RNode LoRa-side proof client on `/dev/cu.usbmodem114401`
+- Command pattern used:
+ - server: `tests/proof_probe.py server --side tcp --work-dir tests/proof_probe_mixed_lora_to_tcp --duration 120 --announce-interval 15 --debug`
+ - client: `tests/proof_probe.py client --side lora --work-dir tests/proof_probe_mixed_lora_to_tcp --rnode-port /dev/cu.usbmodem114401 --frequency 914875000 --bandwidth 125000 --spreadingfactor 10 --codingrate 5 --txpower 14 --path-timeout 60 --timeout 45 --payload 'mixed baseline lora->tcp' --debug`
+- Result:
+ - LoRa client learned path to the TCP-side destination in `3.3s`
+ - LoRa client sent payload length `24`
+ - LoRa client received delivery proof with `CLIENT_DELIVERED rtt=2.807s`
+ - TCP-side server logged `SERVER_RX side=tcp len=24 ... from=TCPInterface[RTNode TCP/mynode.local:4242]`
+- RTNode serial capture during the pass showed the expected boundary/proof path:
+ - stored the TCP-side announce locally: `[PATH] STORED dst=6d9b8639 hops=0 iface=Interface[LocalTcpInterface]`
+ - answered the incoming LoRa path request: `[PATH] REQ dst=6d9b8639 from=Interface[LoRaInterface]` followed by `[PATH] RESP dst=6d9b8639 hops=0 to=Interface[LoRaInterface] local=0`
+ - received the LoRa-side payload over LoRa: `[LoRa] RX 131 bytes`
+ - transported the generated proof back toward LoRa: `[PROOF] XPORT dst=84c8b837 data=64 hops=0 recv=Interface[LocalTcpInterface] out=Interface[LoRaInterface]`
+ - actually transmitted the proof over LoRa: `[LoRa] TXSTART 83 bytes`
+- Updated interpretation:
+ - On the restored mixed baseline, the LoRa-client -> RTNode -> TCP-server path now works with proof return to the LoRa client.
+ - This directly contradicts the earlier failing mixed-baseline symptom for this direction.
+ - The remaining proof-style check to run is the reverse direction: TCP client -> RTNode -> LoRa server.
+
+## 2026-05-10 Mixed Proof Test: TCP Client -> RTNode -> LoRa Server PASS
+
+- Active devices during this test:
+ - RTNode under test on `/dev/cu.usbmodem11101`
+ - standard RNode control on `/dev/cu.usbmodem114401`
+- Test direction run:
+ - WAN-side LoRa proof server on the standard RNode with work dir `tests/proof_probe_mixed_tcp_to_lora`
+ - LAN-side TCP proof client through RTNode local TCP with payload `mixed baseline tcp->lora`
+- LoRa-side server result:
+ - `SERVER_READY side=lora hash=13f35cd143398c24eeb499c5c775b16e`
+ - answered the incoming path request for the local destination
+ - `SERVER_RX side=lora len=24 hash=ae08e296f9b13f18b0066e8ed83a9db8dc3f082dd20d96bc6faff175c990a814 from=RNodeInterface[RNode LoRa]`
+- The TCP-side client run completed successfully with exit code `0`; the reverse proof path did not time out.
+- RTNode serial capture during the pass showed the expected reverse-path behavior:
+ - received the LAN-side path request locally: `[PATH] REQ dst=13f35cd1 from=Interface[LocalTcpInterface] local=1 sz=32`
+ - learned the LoRa-side announce: `[ANNC] IN dst=13f35cd1 valid=1 ctx=11 hops=1 iface=Interface[LoRaInterface]` and `[PATH] STORED dst=13f35cd1 hops=1 iface=Interface[LoRaInterface]`
+ - forwarded the LAN TCP payload toward LoRa: `[PKT] IN iface=Interface[LocalTcpInterface] sz=147 type=0 ctx=0 hdr=1 dstt=0 hops=1 dst=13f35cd1 tid=11a63924` followed by `[LoRa] TXSTART 131 bytes`
+ - transported the returning proof back from LoRa to the LAN TCP client: `[PROOF] XPORT dst=ae08e296 data=64 hops=1 recv=Interface[LoRaInterface] out=Interface[LocalTcpInterface]`
+- Updated interpretation:
+ - The mixed announce/access path is now confirmed in both directions on the restored RTNode + standard-RNode split.
+ - The earlier cross-boundary proof failure is no longer reproducing in either direction with `tests/proof_probe.py`.
+
+## 2026-05-10 Pure LAN TCP -> TCP by Direct IP PASS
+
+- The first LAN-only attempt using `mynode.local` was confounded by TCP connection timeouts and an unsafe concurrent raw serial capture.
+- Important serial-access lesson from that failed attempt:
+ - direct `pyserial` capture on `/dev/cu.usbmodem11101` is unsafe for RTNode in this setup
+ - the partial capture file `tests/proof_probe_lan_tcp_to_tcp_rtnode_serial.log` later showed `boot:0x1 (DOWNLOAD(USB/UART0))`, meaning the ESP32 had been knocked into ROM download mode instead of normal runtime
+ - safer RTNode monitoring is `pio device monitor -p /dev/cu.usbmodem11101 -b 115200 --filter direct --dtr 0 --rts 0`
+- Retested the LAN-only proof using RTNode's direct IP `192.168.2.122` to remove mDNS/name-resolution noise:
+ - TCP-side client destination hash: `935208f1f301a3c75058783de9fb79b8`
+ - `CLIENT_PATH_READY elapsed=15.7s`
+ - `CLIENT_SENT packet_hash=726ed709d1861e10980c1f293a0dca5c9900b76e3d189cc0c37a9e6155735424 len=21`
+ - `CLIENT_DELIVERED rtt=1.984s`
+- Updated interpretation:
+ - Pure LAN-side local TCP clients can announce and access each other through RTNode when RTNode is healthy and addressed directly by IP.
+ - The earlier LAN-only failure was a test artifact driven by mDNS/connection timing and unsafe serial-port access, not a proof of broken local TCP forwarding.
+
+## 2026-05-10 WAN Flood Postcheck: LAN Reachability PASS, Unsolicited WAN Announces NOT Blocked
+
+- Flood setup:
+ - persistent LAN-side TCP proof server on `192.168.2.122:4242`
+ - LAN server destination hash: `3ee511e6579fb20cc57906e8c55f7bbd`
+ - WAN flood script sent four unsolicited LoRa announces for hashes:
+ - `c35fc03cb99e0629367b302c6294f231`
+ - `ace624d1ea08b19a3bf381118a3043b3`
+ - `04c0b7bc075a0cc37890ece246257011`
+ - `4f0501d0fe9669610d37f5e163eba3cc`
+ - WAN flood script also sent twenty random path requests over LoRa
+- Important filter verdict from RTNode's own monitor log:
+ - there were no `BOUNDARY: BLOCKED unsolicited backbone announce` lines in `tests/filter_stress_rtnode_serial.log`
+ - instead, RTNode accepted and stored all four unsolicited WAN announces, for example:
+ - `[ANNC] IN dst=c35fc03c valid=1 ctx=0 hops=1 iface=Interface[LoRaInterface]`
+ - `[PATH] STORED dst=c35fc03c hops=1 iface=Interface[LoRaInterface]`
+ - repeated similarly for `ace624d1`, `04c0b7bc`, and `4f0501d0`
+- The LAN-side TCP server also learned those four unsolicited WAN destinations through RTNode:
+ - `Destination <c35fc03cb99e0629367b302c6294f231> is now 2 hops away via <11a6392444a36824dd8a0e7b9caba59d> on TCPInterface[RTNode TCP/192.168.2.122:4242]`
+ - likewise for `ace624d1...`, `04c0b7bc...`, and `4f0501d0...`
+- The random WAN path-request flood also reached the LAN-side TCP server, which logged each one and ignored it because no path was known.
+- Despite the WAN-side noise, valid LAN reachability still survived:
+ - post-flood LoRa client result:
+ - `CLIENT_PATH_READY elapsed=33.5s`
+ - `CLIENT_SENT packet_hash=0e27b5d98041e2575e61cd851544eedd8cfe66a170c53bf0b61fde39134b8c91 len=26`
+ - `CLIENT_DELIVERED rtt=13.027s`
+ - LAN-side TCP server result:
+ - `SERVER_RX side=tcp len=26 hash=0e27b5d98041e2575e61cd851544eedd8cfe66a170c53bf0b61fde39134b8c91 from=TCPInterface[RTNode TCP/192.168.2.122:4242]`
+ - RTNode serial log showed the valid path/proof sequence after the flood:
+ - `[PATH] REQ dst=3ee511e6 from=Interface[LoRaInterface] local=0 sz=32`
+ - `[PATH] RESP dst=3ee511e6 hops=0 to=Interface[LoRaInterface] local=0`
+ - `[PROOF] XPORT dst=0e27b5d9 data=64 hops=0 recv=Interface[LocalTcpInterface] out=Interface[LoRaInterface]`
+- Updated interpretation:
+ - The current firmware preserves communication with the LAN-side TCP destination even after a WAN-side flood, though path discovery is slower under load.
+ - The current firmware does not appear to enforce the intended unsolicited-WAN announce block yet; the flood announces were accepted into RTNode state and surfaced to the LAN TCP endpoint.
+ - WAN-side random path requests were tolerated rather than blocked at the boundary, so the filter behavior is still weaker than the intended design in `CORE_PRINCIPLES.md`.
+
+## 2026-05-10 Firewall Root Cause, Two-Step Fix, and Final Hardware Validation PASS
+
+- Root cause found in `lib/microReticulum/src/Transport.cpp`:
+ - RTNode firmware config marks LoRa as `MODE_GATEWAY`, not `is_backbone(true)`.
+ - The old firewall logic treated "non-backbone" as trusted, so LoRa/WAN traffic could grow the whitelist and store unsolicited announces.
+ - `Transport::path_request()` also allowed unknown path requests from untrusted LoRa ingress to search and leak toward local TCP clients.
+- First firmware fix that was built, flashed, and validated on hardware:
+ - introduced trusted/untrusted boundary helpers based on `Transport::is_local_client_interface()`
+ - applied the inbound whitelist gate to untrusted ingress, which includes LoRa in this runtime setup
+ - restricted whitelist growth and unknown-path discovery so unsolicited LoRa announces were no longer stored and random unknown LoRa path requests no longer reached the LAN TCP proof server
+ - preserved valid post-flood access: LoRa postcheck client still reached the LAN TCP server and received proof
+- Intermediate validation result after that first fix:
+ - unsolicited LoRa flood announces no longer produced `[ANNC] IN` / `[PATH] STORED` for the flood hashes on RTNode
+ - LAN TCP proof server no longer learned those WAN flood destinations and no longer logged the random path requests
+ - however, `bma` still climbed during the random WAN path-request flood because the "mentioned" set was still learning non-address identifiers such as the control destination and proof hashes
+- Final refinement requested by user and validated on hardware:
+ - tightened whitelist growth to actual addresses only
+ - added `is_boundary_address_packet(packet)` and only allow whitelist growth for `destination_type == SINGLE` and `packet_type != PROOF`
+ - this stopped plain control destinations and proof packet hashes from entering the boundary whitelist state
+- Final hardware validation on the refined firmware:
+ - LAN TCP proof server hash: `703d29c1883e35f48c25beef9ee00b89`
+ - WAN flood script again sent four unsolicited LoRa announces and twenty random LoRa path requests
+ - RTNode monitor during the flood showed `bma=0` throughout the unsolicited announce flood and throughout all twenty random WAN path requests
+ - valid post-flood path request for `703d29c1...` produced:
+ - `[PATH] REQ dst=703d29c1 from=Interface[LoRaInterface] local=0 sz=32`
+ - `[PATH] RESP dst=703d29c1 hops=0 to=Interface[LoRaInterface] local=0`
+ - `bma` remained `0` after the path response
+ - valid post-flood LoRa client then delivered successfully:
+ - `CLIENT_PATH_READY elapsed=3.3s`
+ - `CLIENT_SENT packet_hash=c90cc690f2f2703ddd50b6f8e6ae6bd3981c10e854da38c43d295abca285a237 len=29`
+ - `CLIENT_DELIVERED rtt=2.860s`
+ - LAN TCP server received the payload:
+ - `SERVER_RX side=tcp len=29 hash=c90cc690f2f2703ddd50b6f8e6ae6bd3981c10e854da38c43d295abca285a237 from=TCPInterface[RTNode TCP/192.168.2.122:4242]`
+ - RTNode monitor showed the valid inbound data packet raised `bma` to `1` for the real destination address, and the returning proof did not increase `bma` further
+- Final interpretation:
+ - unsolicited LoRa/WAN announces are no longer being admitted into RTNode path state
+ - random unknown LoRa/WAN path requests no longer leak to LAN TCP clients
+ - the boundary whitelist no longer grows from WAN flood control/proof identifiers
+ - valid LoRa-to-LAN access still works after the flood, which is the required behavior for the two-whitelist policy
+
+## 2026-05-10 Proof Harness Orchestrator Added; Current Execution State
+
+- What was added:
+ - `tests/proof_probe_harness.py` now supports orchestrated `run` and `run-all` commands.
+ - The orchestrator starts the server side, waits for readiness, runs the client side, captures per-scenario logs, and stops the server.
+ - Each scenario now passes an explicit per-scenario `--hash-file` under its own work directory.
+- Important harness lesson:
+ - `tests/proof_probe.py` does not derive the hash file from `--work-dir`; its default hash path is the shared `tests/proof_probe_state/server_hash.txt`.
+ - Without an explicit `--hash-file`, multiple scenarios can accidentally share readiness state.
+- Command used for the live suite:
+
+```bash
+.venv/bin/python tests/proof_probe_harness.py run-all \
+ --tcp-host 192.168.2.122 \
+ --rnode-port /dev/cu.usbmodem114401 \
+ --frequency 914875000 \
+ --bandwidth 125000 \
+ --spreadingfactor 10 \
+ --codingrate 5 \
+ --txpower 14 \
+ --debug
+```
+
+- Actual results from this run:
+ - `local-tcp-to-local-tcp`: PASS
+ - `CLIENT_PATH_READY elapsed=13.9s`
+ - `CLIENT_SENT ... len=30`
+ - `CLIENT_DELIVERED rtt=1.777s`
+ - `lora-to-local-tcp`: FAIL in current environment
+ - `local-tcp-to-wan`: FAIL in current environment
+ - `wan-to-local-tcp`: FAIL in current environment
+- Root cause of the three LoRa-involved failures:
+ - the expected LoRa peer device `/dev/cu.usbmodem114401` was not present during execution
+ - the current host only exposed `/dev/cu.usbmodem11101`
+ - LoRa-side client logs repeatedly showed `could not open port /dev/cu.usbmodem114401: [Errno 2] No such file or directory`
+- Interpretation:
+ - the orchestrator itself is functioning; it successfully ran the pure LAN TCP scenario end to end
+ - the remaining failures are not evidence of a new Reticulum regression in RTNode path handling
+ - to complete the mixed and WAN scenario execution, the standard RNode peer must be reattached or the harness must be rerun with the current correct LoRa device path
+
+## 2026-05-10 Final Orchestrated Pass After Reattach and Boundary Response Fixes
+
+- Reattached hardware state:
+ - RTNode remained on `/dev/cu.usbmodem11101`
+ - reattached standard RNode peer appeared as `/dev/cu.usbmodem11301`
+- First retest after reattach:
+ - `local-tcp-to-local-tcp`: PASS
+ - `lora-to-local-tcp`: PASS
+ - `wan-to-local-tcp`: PASS
+ - `local-tcp-to-wan`: still failing
+- Root cause 1 for `local-tcp-to-wan`:
+ - the firewall treated a WAN `PATH_RESPONSE` as solicited only if it arrived as `HEADER_2` and addressed to the transport identity
+ - real Reticulum destinations answer path requests with a normal announce packet shape plus `context=PATH_RESPONSE`
+ - fix: allow any `PATH_RESPONSE` whose destination hash matches an outstanding `_discovery_path_requests` entry
+- Root cause 2 for repeated `local-tcp-to-wan` lookups:
+ - after the first successful lookup, RTNode already knew the WAN path and answered later LAN requests from the known-path branch
+ - that branch sent an immediate local `PATH_RESPONSE`, but a fresh TCP client could still miss that response and then never receive a queued retry under `FIREWALL_MODE`
+ - fix:
+ - stop requiring `Identity::recall()` success before synthesizing a known-path local `PATH_RESPONSE`
+ - keep a queued retry for trusted local interfaces one announce tick later instead of suppressing all queued path responses under firewall mode
+- Firmware actions:
+ - built and flashed `pio run -e rtnode_heltec_v4 -t upload --upload-port /dev/cu.usbmodem11101`
+ - safe RTNode runtime trace via `pio device monitor -p /dev/cu.usbmodem11101 -b 115200 --filter direct --dtr 0 --rts 0`
+- Discriminating runtime evidence from RTNode:
+ - second-run `local-tcp-to-wan` showed RTNode receiving the LAN path request and sending `[PATH] RESP dst=6c4eec98 hops=1 to=Interface[LocalTcpInterface] local=1`
+ - that proved the remaining bug was reliability of local response delivery for repeated lookups, not WAN discovery itself
+- Final repeated-request validation:
+ - `local-tcp-to-wan` PASS on first run
+ - `local-tcp-to-wan` PASS again immediately on the second run
+- Final orchestrated suite command:
+
+```bash
+.venv/bin/python tests/proof_probe_harness.py run-all \
+ --tcp-host 192.168.2.122 \
+ --rnode-port /dev/cu.usbmodem11301 \
+ --frequency 914875000 \
+ --bandwidth 125000 \
+ --spreadingfactor 10 \
+ --codingrate 5 \
+ --txpower 14 \
+ --debug
+```
+
+- Final results:
+ - `local-tcp-to-local-tcp`: PASS
+ - `CLIENT_PATH_READY elapsed=2.0s`
+ - `CLIENT_DELIVERED rtt=0.045s`
+ - `lora-to-local-tcp`: PASS
+ - `CLIENT_PATH_READY elapsed=3.3s`
+ - `CLIENT_DELIVERED rtt=3.039s`
+ - `local-tcp-to-wan`: PASS
+ - `CLIENT_PATH_READY elapsed=1.3s`
+ - `CLIENT_DELIVERED rtt=3.086s`
+ - `wan-to-local-tcp`: PASS
+ - `CLIENT_PATH_READY elapsed=3.1s`
+ - `CLIENT_DELIVERED rtt=2.952s`
+- Final interpretation:
+ - the orchestrator is now valid for all four requested scenarios on current hardware
+ - strict boundary filtering still holds
+ - both WAN discovery from LAN and WAN access into LAN are now working on the patched RTNode firmware
\ No newline at end of file

diff --git a/Makefile b/Makefile
index 8562066..d54fdcd 100755
--- a/Makefile
+++ b/Makefile
@@ -287,11 +287,15 @@ release-all: console-site spiffs-image release-tbeam release-tbeam_sx1262 releas
# by flash.py. Individual binaries are stored flat inside the zip.
release-pio:
pio run -e rtnode_heltec_v4 -e rtnode_heltec_v3
+ python3 flash.py --board v4 --merge-only --offline
+ python3 flash.py --board v3 --merge-only --offline
python3 -c "\
import zipfile, os, sys; \
variants = [ \
('.pio/build/rtnode_heltec_v4', 'rtnode_heltec_v4.bin'), \
+ ('.pio/build/rtnode_heltec_v4', 'rtnode_heltec_v4_merged.bin'), \
('.pio/build/rtnode_heltec_v3', 'rtnode_heltec_v3.bin'), \
+ ('.pio/build/rtnode_heltec_v3', 'rtnode_heltec_v3_merged.bin'), \
]; \
missing = [(d,n) for d,n in variants if not os.path.isfile(os.path.join(d,n))]; \
[sys.exit(f'Missing: {os.path.join(d,n)}') for d,n in missing]; \

diff --git a/README.md b/README.md
index 6d9d6df..8f9725d 100755
--- a/README.md
+++ b/README.md
@@ -4,6 +4,12 @@ A custom firmware for the **Heltec WiFi LoRa 32 V4** (ESP32-S3 + SX1262) that op
This project was primarily developed with the use of AI assistance.
+## Release Policy
+
+All published firmware releases should be treated as **Beta** unless a release is explicitly called stable. In practice, that means they are lightly tested and aimed at early adopters who can validate on real hardware.
+
+For tooling compatibility, version tags can stay numeric (for example `v1.0.30`), but user-facing release labels in docs, flash tools, and release titles should include **Beta**.
+
```
Android / Sideband Remote
┌──────────┐ ┌────────────┐ Reticulum
@@ -65,6 +71,8 @@ Open **[jrl290.github.io/RTNode-HeltecV4](https://jrl290.github.io/RTNode-Heltec
1. **Detect** — click *Detect* and select your device from the browser's serial port picker. The flasher identifies the board (V3 or V4) automatically using PSRAM detection.
2. **Flash** — choose *Update firmware* (app only, settings preserved) or *Full install* (erases everything — use for first-time installs), then click *Flash Firmware*.
+The web flasher presents all published firmware versions as **Beta** so the light-testing status is visible at selection time.
+
> Web Serial requires **Chrome 89+** or **Microsoft Edge**. Firefox and Safari are not supported.
> On Linux, add your user to the `dialout` group first: `sudo usermod -a -G dialout $USER` (then log out and back in).
@@ -77,7 +85,7 @@ The easiest way to flash from the command line. You only need Python 3 and a USB
git clone https://github.com/jrl290/RTNode-HeltecV4.git
cd RTNode-HeltecV4
-# Download latest firmware from GitHub Releases and flash
+# Download the latest Beta firmware from GitHub Releases and flash
# (auto-detects V3 vs V4 from flash size)
python flash.py
@@ -92,7 +100,7 @@ python flash.py --board v4
python flash.py --file rtnode_heltec_v4.bin
```
-By default, `flash.py` uses the bundled `Release/esptool/esptool.py` for reproducible flashing. Only use `--use-system-esptool` if you explicitly want to override that with a host-installed esptool.
+By default, `flash.py` uses the bundled `Release/esptool/esptool.py` for reproducible flashing and labels fetched GitHub firmware as **Beta**. Only use `--use-system-esptool` if you explicitly want to override that with a host-installed esptool.
The flash utility auto-detects whether a V3 or V4 is connected by querying the flash size (8MB = V3, 16MB = V4). You can override with `--board v3` or `--board v4`. It will list all available serial ports and prompt you to choose one. If no ports are detected, you may need to hold the **BOOT** button while pressing **RESET** to enter download mode.
@@ -260,6 +268,8 @@ Only registered when WiFi is enabled and `tcp_mode == 1` (client mode).
When both WiFi and the local TCP server are enabled, a TCP server on the WiFi network allows local Reticulum nodes to connect. It uses `MODE_GATEWAY`, so announces are forwarded freely to and from local TCP clients (matching standard Reticulum transport node behaviour). Also registered as a local-client interface so Transport forwards announces, link packets, and proofs to connected clients.
+Local TCP clients should be endpoint clients, not transport routers. If an application such as Meshchat is configured with Reticulum transport mode enabled, it can relay WAN-scale traffic into RTNode through the LAN side, even when RTNode's own WAN/backbone interface is disabled. That defeats the boundary model and can fill routing/cache state from the trusted side. Disable transport mode on Meshchat/Reticulum clients connected to the Local TCP Server unless you are intentionally testing bounded LAN-side transport behavior.
+
**Implementation details:**
- Each TCP interface must have a **unique name** to produce a unique interface hash — the backbone uses `"TcpInterface"` and the local server uses `"LocalTcpInterface"`. Without distinct names, both interfaces produce the same hash, causing the interface map lookup to fail when routing packets.
- TCP interfaces are configured with a **10 Mbps bitrate**, which causes Reticulum's Transport to prefer TCP paths over LoRa paths (typically ~1–10 kbps) when both are available for the same destination.

diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino
index 03d7dfd..7f135a9 100755
--- a/RNode_Firmware.ino
+++ b/RNode_Firmware.ino
@@ -55,6 +55,7 @@ SPIClass SDSPI(HSPI);
#if MCU_VARIANT == MCU_ESP32
#include <esp_task_wdt.h>
+ #include <esp_heap_caps.h>
#endif
// WDT timeout
@@ -76,6 +77,8 @@ volatile uint16_t queued_bytes = 0;
volatile uint16_t queue_cursor = 0;
volatile uint16_t current_packet_start = 0;
volatile bool serial_buffering = false;
+static uint8_t last_lora_phy_header = 0;
+static bool last_lora_phy_header_valid = false;
#if HAS_BLUETOOTH || HAS_BLE == true
bool bt_init_ran = false;
#endif
@@ -90,9 +93,27 @@ volatile bool serial_buffering = false;
size_t len;
int rssi;
int snr_raw;
+ uint8_t phy_header;
uint8_t data[];
} modem_packet_t;
static xQueueHandle modem_packet_queue = NULL;
+
+ static modem_packet_t* modem_packet_alloc(size_t len) {
+ size_t allocation_size = sizeof(modem_packet_t) + len;
+ #if MCU_VARIANT == MCU_ESP32
+ if (ESP.getPsramSize() > 0) {
+ modem_packet_t *packet = (modem_packet_t*)heap_caps_malloc(allocation_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+ if (packet) return packet;
+ }
+ return (modem_packet_t*)heap_caps_malloc(allocation_size, MALLOC_CAP_8BIT);
+ #else
+ return (modem_packet_t*)malloc(allocation_size);
+ #endif
+ }
+
+ static void modem_packet_free(modem_packet_t *packet) {
+ free(packet);
+ }
#endif
char sbuf[128];
@@ -124,12 +145,14 @@ public:
}
protected:
virtual void handle_incoming(const RNS::Bytes& data) {
+ VERBOSEF("[LoRa] RX %u bytes", data.size());
TRACEF("LoRaInterface.handle_incoming: (%u bytes) data: %s", data.size(), data.toHex().c_str());
TRACE("LoRaInterface.handle_incoming: sending packet to rns...");
InterfaceImpl::handle_incoming(data);
}
virtual void send_outgoing(const RNS::Bytes& data) {
// CBA NOTE header will be addded later by transmit function
+ VERBOSEF("[LoRa] TX %u bytes", data.size());
TRACEF("LoRaInterface.send_outgoing: (%u bytes) data: %s", data.size(), data.toHex().c_str());
TRACE("LoRaInterface.send_outgoing: adding packet to outgoing queue...");
for (size_t i = 0; i < data.size(); i++) {
@@ -944,6 +967,7 @@ void setup() {
*/
RNS::Destination destination(RNS::Transport::identity(), RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "rnstransport", "local");
+#ifdef FIREWALL_MODE
// Cache this node's destination hash in RTC memory so the captive-portal
// config page can show it without needing RNS to be running.
{
@@ -955,7 +979,6 @@ void setup() {
rtc_node_hash_magic = NODE_HASH_RTC_MAGIC;
}
-#ifdef FIREWALL_MODE
// Initialise the Reticulum interface-discovery announcer. Per the
// Reticulum manual (https://reticulum.network/manual/interfaces.html)
// this announces this node and its parameters on the network so that
@@ -1032,6 +1055,13 @@ inline void kiss_write_packet() {
// CBA RESERVE
//RNS::Bytes data();
RNS::Bytes data(512);
+#ifdef FIREWALL_MODE
+ if (last_lora_phy_header_valid && host_write_len > 2 && pbuf[1] > 16) {
+ VERBOSEF("[LoRa] RX raw-shift fix: prepend 0x%02x (hops byte was %u)",
+ last_lora_phy_header, (unsigned)pbuf[1]);
+ data << last_lora_phy_header;
+ }
+#endif
for (uint16_t i = 0; i < host_write_len; i++) {
#if MCU_VARIANT == MCU_NRF52
portENTER_CRITICAL();
@@ -1043,6 +1073,7 @@ inline void kiss_write_packet() {
data << byte;
}
lora_interface.handle_incoming(data);
+ last_lora_phy_header_valid = false;
#endif
serial_write(FEND);
@@ -1105,6 +1136,22 @@ void ISR_VECT receive_callback(int packet_size) {
uint8_t sequence = packetSequence(header);
bool ready = false;
+ #ifdef FIREWALL_MODE
+ // Some Reticulum LoRa peers transmit raw RNS frames without the
+ // RNode split/framing byte. If we strip the first byte in that case,
+ // the RNS header shifts left and packets unpack as nonsense hops and
+ // contexts. Non-split RNode framing uses a low nibble of 0; split
+ // RNode frames are full-size fragments. Raw RNS control/announce
+ // frames seen here have a non-zero low nibble and fit in one LoRa frame.
+ if ((header & 0x0F) != 0 && (packet_size + 1) < SINGLE_MTU) {
+ read_len = 0;
+ pbuf[read_len++] = header;
+ getPacketData(packet_size);
+ ready = true;
+ }
+ else
+ #endif
+
if (isSplitPacket(header) && seq == SEQ_UNSET) {
// This is the first part of a split
// packet, so we set the seq variable
@@ -1195,7 +1242,7 @@ void ISR_VECT receive_callback(int packet_size) {
#else
// Allocate packet struct, but abort if there
// is not enough memory available.
- modem_packet_t *modem_packet = (modem_packet_t*)malloc(sizeof(modem_packet_t) + read_len);
+ modem_packet_t *modem_packet = modem_packet_alloc(read_len);
if(!modem_packet) { memory_low = true; return; }
// Get packet RSSI and SNR
@@ -1203,6 +1250,7 @@ void ISR_VECT receive_callback(int packet_size) {
modem_packet->snr_raw = LoRa->packetSnrRaw();
modem_packet->rssi = LoRa->packetRssi(modem_packet->snr_raw);
#endif
+ modem_packet->phy_header = header;
// Send packet to event queue, but free the
// allocated memory again if the queue is
@@ -1210,7 +1258,7 @@ void ISR_VECT receive_callback(int packet_size) {
modem_packet->len = read_len;
memcpy(modem_packet->data, pbuf, read_len); read_len = 0;
if (!modem_packet_queue || xQueueSendFromISR(modem_packet_queue, &modem_packet, NULL) != pdPASS) {
- free(modem_packet);
+ modem_packet_free(modem_packet);
}
#endif
}
@@ -1448,6 +1496,7 @@ void update_airtime() {
}
void transmit(uint16_t size) {
+ VERBOSEF("[LoRa] TXSTART %u bytes", size);
if (radio_online) {
if (!promisc) {
uint16_t written = 0;
@@ -2276,6 +2325,9 @@ void validate_status() {
hw_ready = true;
eeprom_ok = true;
device_init_done = true;
+ #if BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_HELTEC32_V3
+ model = MODEL_C8;
+ #endif
Serial.write("[Boundary] Provisioning check bypassed, modem installed\r\n");
// Load LoRa config from EEPROM (written by config portal)
@@ -2291,8 +2343,16 @@ void validate_status() {
lora_txp = 28;
Serial.write("[Boundary] No LoRa config in EEPROM, using defaults\r\n");
}
+ // Always log the active channel config so tests/diagnostics can verify it
+ Serial.printf("[Boundary] LoRa: freq=%lu bw=%lu sf=%u cr=%u txp=%u\r\n",
+ (unsigned long)lora_freq, (unsigned long)lora_bw,
+ (unsigned)lora_sf, (unsigned)lora_cr, (unsigned)lora_txp);
op_mode = MODE_TNC;
+ // In FIREWALL_MODE (US915) there are no duty-cycle regulations;
+ // disable interference avoidance so CSMA does not block TX due to
+ // ambient non-LoRa RF energy on the 914 MHz band.
+ avoid_interference = false;
startRadio();
} else {
hw_ready = false;
@@ -2400,6 +2460,7 @@ void validate_status() {
}
#endif
+static uint32_t _tx_blocked_last_log = 0;
void tx_queue_handler() {
if (!airtime_lock && queue_height > 0) {
if (csma_cw == -1) {
@@ -2409,7 +2470,15 @@ void tx_queue_handler() {
if (difs_wait_start == -1) { // DIFS wait not yet started
if (medium_free()) { difs_wait_start = millis(); return; } // Set DIFS wait start time
- else { return; } } // Medium not yet free, continue waiting
+ else {
+ uint32_t _now = millis();
+ if (_now - _tx_blocked_last_log >= 2000) {
+ _tx_blocked_last_log = _now;
+ VERBOSEF("[LoRa] TX BLOCKED: dcd=%d avoidint=%d interference=%d rssi=%d noise=%d",
+ (int)dcd, (int)avoid_interference, (int)interference_detected,
+ (int)current_rssi, (int)noise_floor);
+ }
+ return; } } // Medium not yet free, continue waiting
else { // We are waiting for DIFS or CW to pass
if (!medium_free()) { difs_wait_start = -1; cw_wait_start = -1; return; } // Medium became occupied while in DIFS wait, restart waiting when free again
@@ -2554,8 +2623,10 @@ void loop() {
host_write_len = modem_packet->len;
last_rssi = modem_packet->rssi;
last_snr_raw = modem_packet->snr_raw;
+ last_lora_phy_header = modem_packet->phy_header;
+ last_lora_phy_header_valid = true;
memcpy(&pbuf, modem_packet->data, modem_packet->len);
- free(modem_packet);
+ modem_packet_free(modem_packet);
modem_packet = NULL;
kiss_indicate_stat_rssi();
@@ -2572,7 +2643,9 @@ void loop() {
if(modem_packet_queue && xQueueReceive(modem_packet_queue, &modem_packet, 0) == pdTRUE && modem_packet) {
memcpy(&pbuf, modem_packet->data, modem_packet->len);
host_write_len = modem_packet->len;
- free(modem_packet);
+ last_lora_phy_header = modem_packet->phy_header;
+ last_lora_phy_header_valid = true;
+ modem_packet_free(modem_packet);
modem_packet = NULL;
portENTER_CRITICAL();

diff --git a/Utilities.h b/Utilities.h
index f6dfb7c..9316e5c 100755
--- a/Utilities.h
+++ b/Utilities.h
@@ -1448,6 +1448,7 @@ int map_modem_output_to_target_power(int modem_output_dbm) {
void setTXPower() {
if (radio_online) {
+ int requested_lora_txp = lora_txp;
int mapped_lora_txp = map_target_power_to_modem_output(lora_txp);
#if HAS_LORA_PA
@@ -1455,6 +1456,17 @@ void setTXPower() {
lora_txp = real_lora_txp;
#endif
+ #if defined(FIREWALL_MODE) && (BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_HELTEC32_V3)
+ Serial.printf("[Boundary] TXP: requested=%d effective=%d modem=%d pa=%u\r\n",
+ requested_lora_txp, lora_txp, mapped_lora_txp,
+ #if HAS_LORA_PA
+ (unsigned)lora_pa_model
+ #else
+ 0u
+ #endif
+ );
+ #endif
+
if (model == MODEL_11) LoRa->setTxPower(mapped_lora_txp, PA_OUTPUT_RFO_PIN);
if (model == MODEL_12) LoRa->setTxPower(mapped_lora_txp, PA_OUTPUT_RFO_PIN);

diff --git a/docs/firmware/rtnode_heltec_v3.bin b/docs/firmware/rtnode_heltec_v3.bin
index 72f755b..097b7a3 100644
Binary files a/docs/firmware/rtnode_heltec_v3.bin and b/docs/firmware/rtnode_heltec_v3.bin differ

diff --git a/docs/firmware/rtnode_heltec_v3_merged.bin b/docs/firmware/rtnode_heltec_v3_merged.bin
index ccb129f..939d9f7 100644
Binary files a/docs/firmware/rtnode_heltec_v3_merged.bin and b/docs/firmware/rtnode_heltec_v3_merged.bin differ

diff --git a/docs/firmware/rtnode_heltec_v4.bin b/docs/firmware/rtnode_heltec_v4.bin
index 3732f1e..ceac720 100644
Binary files a/docs/firmware/rtnode_heltec_v4.bin and b/docs/firmware/rtnode_heltec_v4.bin differ

diff --git a/docs/firmware/rtnode_heltec_v4_merged.bin b/docs/firmware/rtnode_heltec_v4_merged.bin
index 2f587f8..3f679ef 100644
Binary files a/docs/firmware/rtnode_heltec_v4_merged.bin and b/docs/firmware/rtnode_heltec_v4_merged.bin differ

diff --git a/docs/firmware/versions.json b/docs/firmware/versions.json
index 706eca2..44920dc 100644
--- a/docs/firmware/versions.json
+++ b/docs/firmware/versions.json
@@ -1,31 +1,8 @@
[
- {
- "tag": "v1.0.32",
- "name": "v1.0.32 \u2014 LED off, mDNS/Firewall Mode, TCP keepalive fix",
- "prerelease": false,
- "published_at": "2026-05-09T01:21:56Z",
- "assets": [
- "rtnode_heltec_v3.bin",
- "rtnode_heltec_v3_merged.bin",
- "rtnode_heltec_v4.bin",
- "rtnode_heltec_v4_merged.bin"
- ]
- },
- {
- "tag": "v1.0.31",
- "name": "v1.0.31 \u2014 Fix LoRa routing: all interfaces MODE_GATEWAY",
- "prerelease": false,
- "published_at": "2026-05-08T22:46:31Z",
- "assets": [
- "rtnode_heltec_v3.bin",
- "rtnode_heltec_v3_merged.bin",
- "rtnode_heltec_v4.bin",
- "rtnode_heltec_v4_merged.bin"
- ]
- },
{
"tag": "v1.0.30",
"name": "v1.0.30 \u2014 Heltec V4 boot reliability (DIO image header)",
+ "stability": "beta",
"prerelease": false,
"published_at": "2026-05-01T21:22:08Z",
"assets": [
@@ -38,6 +15,7 @@
{
"tag": "v1.0.29",
"name": "v1.0.29 \u2014 Heltec V4.3 boot/RX fix",
+ "stability": "beta",
"prerelease": false,
"published_at": "2026-04-28T21:40:31Z",
"assets": [
@@ -50,6 +28,7 @@
{
"tag": "v1.0.28",
"name": "v1.0.28 \u2014 Fix LoRa routing (MODE_FULL)",
+ "stability": "beta",
"prerelease": false,
"published_at": "2026-04-28T18:14:26Z",
"assets": [
@@ -62,6 +41,7 @@
{
"tag": "v1.0.27",
"name": "v1.0.27 \u2014 Configurable LoRa airtime limits",
+ "stability": "beta",
"prerelease": false,
"published_at": "2026-04-27T03:27:06Z",
"assets": [
@@ -74,6 +54,7 @@
{
"tag": "v1.0.26",
"name": "v1.0.26 \u2014 Heltec V4.3 support",
+ "stability": "beta",
"prerelease": false,
"published_at": "2026-04-27T02:46:30Z",
"assets": [

diff --git a/docs/index.html b/docs/index.html
old mode 100644
new mode 100755
index cdaf9ae..f732a20
--- a/docs/index.html
+++ b/docs/index.html
@@ -304,6 +304,7 @@
<header>
<h1><span>RT</span>Node Firmware Flasher</h1>
<p>Flash RTNode firmware directly from your browser &mdash; no Python or tools required.</p>
+ <p><strong>Release track:</strong> All published firmware versions are labeled <strong>Beta</strong> and should be treated as lightly tested unless explicitly marked stable.</p>
</header>
<!-- Step 1: Detect -->
@@ -324,7 +325,7 @@
<div class="version-row">
<label for="version-select">Version</label>
<select id="version-select">
- <option value="latest">Latest (bundled)</option>
+ <option value="latest">Latest Beta (bundled)</option>
</select>
</div>
@@ -432,8 +433,18 @@
// Older releases live under firmware/<tag>/ because GitHub's release-download
// URLs do not expose CORS headers and so cannot be fetched from the browser.
// Run docs/mirror_releases.py to refresh the mirror.
+ const RELEASE_CHANNEL = 'Beta';
const versionAssets = new Map();
+ function formatReleaseLabel(tag, name, stability) {
+ const channel = stability ? String(stability) : RELEASE_CHANNEL;
+ const base = `${tag}${name && name !== tag ? ' — ' + name : ''}`;
+ if (base.toLowerCase().includes(channel.toLowerCase())) {
+ return base;
+ }
+ return `${base} (${channel})`;
+ }
+
function getBinUrl(fw, isUpdate) {
const name = isUpdate ? fw.updateName : fw.fullName;
const ver = versionSelect.value;
@@ -457,7 +468,7 @@
versionAssets.set(v.tag, new Set(v.assets || []));
const opt = document.createElement('option');
opt.value = v.tag;
- opt.textContent = `${v.tag}${v.name && v.name !== v.tag ? ' — ' + v.name : ''}`;
+ opt.textContent = formatReleaseLabel(v.tag, v.name, v.stability);
versionSelect.appendChild(opt);
}
} catch (_) { /* manifest missing — latest (bundled) remains the only option */ }
@@ -618,14 +629,14 @@
if (!assetAvailable(ver, assetName)) {
const other = currentIsUpdate ? 'Full install' : 'Update firmware';
throw new Error(
- `Release ${ver} does not include ${assetName}. ` +
+ `${RELEASE_CHANNEL} release ${ver} does not include ${assetName}. ` +
`Try the "${other}" mode, or pick a different version.`
);
}
const binUrl = getBinUrl(fw, currentIsUpdate);
const addr = currentIsUpdate ? fw.updateAddr : fw.fullAddr;
- log(`Version: ${ver}`);
+ log(`Version: ${ver === 'latest' ? `Latest bundled ${RELEASE_CHANNEL}` : `${ver} (${RELEASE_CHANNEL})`}`);
log(`Mode: ${currentIsUpdate ? 'Update (app only, settings preserved)' : 'Full install (erase all)'}`);
log(`Firmware: ${binUrl}`);
const resp = await fetch(binUrl);

diff --git a/docs/mirror_releases.py b/docs/mirror_releases.py
new file mode 100644
index 0000000..224d4fa
--- /dev/null
+++ b/docs/mirror_releases.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""Mirror RTNode GitHub release binaries into docs/firmware/<tag>/.
+
+Why: GitHub's release-download URLs do not expose CORS headers, so the
+browser-based web flasher (docs/index.html) cannot fetch them directly from
+jrl290.github.io. By mirroring the .bin assets under GitHub Pages we get a
+same-origin URL the browser is allowed to fetch.
+
+Usage:
+ python3 docs/mirror_releases.py # mirror all known releases
+ python3 docs/mirror_releases.py --max 5 # only the most recent 5
+ python3 docs/mirror_releases.py --tag v1.0.28 # one specific tag
+
+Outputs:
+ docs/firmware/<tag>/<asset>.bin # mirrored binaries
+ docs/firmware/versions.json # manifest consumed by index.html
+
+Convention:
+ All published firmware releases are treated as Beta unless explicitly
+ promoted to a different stability level elsewhere.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import urllib.request
+import urllib.error
+from pathlib import Path
+
+REPO = "jrl290/RTNode-HeltecV4"
+API = f"https://api.github.com/repos/{REPO}/releases"
+ASSET_NAMES = (
+ "rtnode_heltec_v3.bin",
+ "rtnode_heltec_v3_merged.bin",
+ "rtnode_heltec_v4.bin",
+ "rtnode_heltec_v4_merged.bin",
+)
+EXCLUDED_TAGS = {
+ "v1.0.31",
+ "v1.0.32",
+}
+DEFAULT_STABILITY = "beta"
+
+DOCS_DIR = Path(__file__).resolve().parent
+FW_DIR = DOCS_DIR / "firmware"
+MANIFEST = FW_DIR / "versions.json"
+
+
+def gh_get(url: str) -> bytes:
+ req = urllib.request.Request(url, headers={
+ "Accept": "application/vnd.github+json",
+ "User-Agent": "rtnode-mirror-script",
+ })
+ token = os.environ.get("GITHUB_TOKEN")
+ if token:
+ req.add_header("Authorization", f"Bearer {token}")
+ with urllib.request.urlopen(req) as r:
+ return r.read()
+
+
+def list_releases() -> list[dict]:
+ out: list[dict] = []
+ page = 1
+ while True:
+ data = json.loads(gh_get(f"{API}?per_page=100&page={page}"))
+ if not data:
+ break
+ out.extend(data)
+ if len(data) < 100:
+ break
+ page += 1
+ return out
+
+
+def download(url: str, dest: Path) -> None:
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ tmp = dest.with_suffix(dest.suffix + ".part")
+ req = urllib.request.Request(url, headers={"User-Agent": "rtnode-mirror-script"})
+ with urllib.request.urlopen(req) as r, open(tmp, "wb") as f:
+ while True:
+ chunk = r.read(64 * 1024)
+ if not chunk:
+ break
+ f.write(chunk)
+ tmp.replace(dest)
+
+
+def mirror(releases: list[dict]) -> list[dict]:
+ manifest: list[dict] = []
+ for rel in releases:
+ tag = rel["tag_name"]
+ if tag in EXCLUDED_TAGS:
+ print(f" - {tag}: excluded, skipping")
+ continue
+
+ assets = {a["name"]: a for a in rel.get("assets", [])}
+ present = [n for n in ASSET_NAMES if n in assets]
+ if not present:
+ print(f" - {tag}: no recognised .bin assets, skipping")
+ continue
+
+ tag_dir = FW_DIR / tag
+ for name in present:
+ dest = tag_dir / name
+ if dest.exists() and dest.stat().st_size == assets[name]["size"]:
+ print(f" = {tag}/{name} ({dest.stat().st_size} bytes, cached)")
+ continue
+ url = assets[name]["browser_download_url"]
+ print(f" + {tag}/{name} <- {url}")
+ try:
+ download(url, dest)
+ except urllib.error.HTTPError as e:
+ print(f" ! HTTP {e.code} fetching {url}")
+ continue
+
+ manifest.append({
+ "tag": tag,
+ "name": rel.get("name") or tag,
+ "stability": DEFAULT_STABILITY,
+ "prerelease": rel.get("prerelease", False),
+ "published_at": rel.get("published_at"),
+ "assets": sorted(present),
+ })
+ return manifest
+
+
+def main() -> int:
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument("--max", type=int, default=0, help="only mirror the N most recent releases (0 = all)")
+ p.add_argument("--tag", action="append", default=[], help="mirror only this tag (may be repeated)")
+ args = p.parse_args()
+
+ print(f"Fetching release list for {REPO}...")
+ releases = list_releases()
+ if args.tag:
+ releases = [r for r in releases if r["tag_name"] in args.tag]
+ if args.max > 0:
+ releases = releases[: args.max]
+ print(f"Mirroring {len(releases)} release(s) into {FW_DIR}")
+
+ manifest = mirror(releases)
+
+ MANIFEST.parent.mkdir(parents=True, exist_ok=True)
+ MANIFEST.write_text(json.dumps(manifest, indent=2) + "\n")
+ print(f"Wrote {MANIFEST} ({len(manifest)} versions)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())

diff --git a/extra_script.py b/extra_script.py
index 417b680..f14bc1a 100755
--- a/extra_script.py
+++ b/extra_script.py
@@ -61,23 +61,7 @@ def post_upload(source, target, env):
print("Board:", env.GetProjectOption("board"))
print("Variant:", env.GetProjectOption("custom_variant"))
print("Serial port:", env.subst("$UPLOAD_PORT"))
- # do some actions
- platform = env.GetProjectOption("platform")
- board = env.GetProjectOption("board")
- if (platform == "espressif32"):
- time.sleep(10)
- # device provisioning is incomplete and only currently appropriate for 915MHz T-Beam
- device_provision(env)
- firmware_hash(source, env)
- # firmware pacakaging is incomplete due to missing console image
- #firmware_package(env)
- elif (platform == "nordicnrf52"):
- time.sleep(5)
- # device provisioning is incomplete and only currently appropriate for 915MHz RAK4631
- device_provision(env)
- firmware_hash(source, env)
- # firmware pacakaging is incomplete due to missing console image
- #firmware_package(env)
+ print("Skipping rnodeconf post-upload provisioning/hash steps")
def post_clean(source, target, env):
print("post_clean...")

diff --git a/flash.py b/flash.py
index a6b3cf7..0c72390 100755
--- a/flash.py
+++ b/flash.py
@@ -5,7 +5,7 @@ RTNode-HeltecV4 Flash Utility
Flash the RTNode-HeltecV4 transport node firmware to a Heltec WiFi LoRa 32 V3 or V4.
No PlatformIO required — just Python 3 and a USB cable.
-By default, downloads the latest firmware from GitHub Releases (if newer than
+By default, downloads the latest Beta firmware from GitHub Releases (if newer than
the local cache) and flashes the app partition only, preserving bootloader,
partition table, NVS, and EEPROM settings. For reproducible flashing, the
script prefers the bundled esptool in Release/ over any host-installed copy.
@@ -23,7 +23,7 @@ Usage:
# Update firmware — V3
python flash.py --board v3
- # Flash a specific release version
+ # Flash a specific Beta release version
python flash.py --release v1.0.12
# Full flash with merged binary (overwrites everything)
@@ -55,6 +55,8 @@ import time
# ── Configuration ──────────────────────────────────────────────────────────────
VERSION = "1.0.18"
+RELEASE_CHANNEL = "Beta"
+RELEASE_NOTE = "lightly tested unless explicitly marked stable"
CHIP = "esp32s3"
FLASH_MODE = "qio" # Global default; overridden by board profile
FLASH_FREQ = "80m"
@@ -439,6 +441,8 @@ def find_esptool(prefer_system=False):
pio_esptool = os.path.expanduser(
"~/.platformio/packages/tool-esptoolpy/esptool.py"
)
+ pio_python = os.path.expanduser("~/.platformio/penv/bin/python")
+ pio_python_cmd = pio_python if os.path.isfile(pio_python) and os.access(pio_python, os.X_OK) else sys.executable
repo_candidates = []
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
@@ -454,7 +458,7 @@ def find_esptool(prefer_system=False):
# Standalone binary — invoke directly, no Python interpreter prefix
repo_candidates.append(([bundled_bin], f"bundled esptool binary: {bundled_bin}"))
if has_pyserial and os.path.isfile(pio_esptool):
- repo_candidates.append(([sys.executable, pio_esptool], f"PlatformIO esptool: {pio_esptool}"))
+ repo_candidates.append(([pio_python_cmd, pio_esptool], f"PlatformIO esptool: {pio_esptool}"))
system_candidates = []
if shutil.which("esptool.py"):
@@ -617,6 +621,12 @@ def _parse_version_tag(tag):
return None
+def _format_release_label(tag=None):
+ if tag:
+ return f"{tag} ({RELEASE_CHANNEL})"
+ return f"latest {RELEASE_CHANNEL} release"
+
+
def _fetch_release_info(tag=None):
"""Fetch release info from GitHub. If tag is None, fetches latest."""
try:
@@ -665,7 +675,7 @@ def fetch_firmware(board_key, flash_size, release_tag=None):
cache_meta = _read_cache_meta()
# 1. Fetch release info
- label = f"release {release_tag}" if release_tag else "latest release"
+ label = _format_release_label(release_tag)
print(f"Checking {label} from {GITHUB_REPO}...")
release, err = _fetch_release_info(release_tag)
if not release:
@@ -683,7 +693,7 @@ def fetch_firmware(board_key, flash_size, release_tag=None):
cached_tag = cache_meta.get("tag")
if cached_tag == remote_tag:
if sha256_file(archive_path) == cache_meta.get("sha256"):
- print(f" Cached firmware archive is up-to-date: {remote_tag}")
+ print(f" Cached firmware archive is up-to-date: {_format_release_label(remote_tag)}")
need_download = False
else:
print(f" Cache integrity mismatch — re-downloading")
@@ -691,11 +701,11 @@ def fetch_firmware(board_key, flash_size, release_tag=None):
cached_ver = _parse_version_tag(cached_tag) if cached_tag else None
remote_ver = _parse_version_tag(remote_tag)
if cached_ver and remote_ver and remote_ver > cached_ver:
- print(f" Newer version available: {cached_tag} → {remote_tag}")
+ print(f" Newer {RELEASE_CHANNEL} release available: {_format_release_label(cached_tag)} → {_format_release_label(remote_tag)}")
elif cached_ver and remote_ver and remote_ver < cached_ver:
- print(f" Requested version {remote_tag} is older than cached {cached_tag}")
+ print(f" Requested {_format_release_label(remote_tag)} is older than cached {_format_release_label(cached_tag)}")
else:
- print(f" Version changed: {cached_tag} → {remote_tag}")
+ print(f" Version changed: {_format_release_label(cached_tag)} → {_format_release_label(remote_tag)}")
if need_download:
# 3. Locate the archive asset (with per-board fallback for old releases)
@@ -712,7 +722,7 @@ def fetch_firmware(board_key, flash_size, release_tag=None):
os.makedirs(_cache_dir(), exist_ok=True)
if asset_url:
- print(f" Downloading {remote_tag} / {FIRMWARE_ARCHIVE}...")
+ print(f" Downloading {_format_release_label(remote_tag)} / {FIRMWARE_ARCHIVE}...")
try:
urlretrieve(asset_url, archive_path)
except Exception as e:
@@ -736,7 +746,7 @@ def fetch_firmware(board_key, flash_size, release_tag=None):
else:
available = [a["name"] for a in release.get("assets", [])]
return None, (
- f"Neither '{FIRMWARE_ARCHIVE}' nor '{firmware_name}' found in release {remote_tag}.\n"
+ f"Neither '{FIRMWARE_ARCHIVE}' nor '{firmware_name}' found in {_format_release_label(remote_tag)}.\n"
f" Available assets: {available}"
)
@@ -1223,7 +1233,7 @@ Examples:
python flash.py --board v3
Download latest firmware and flash a V3 board.
python flash.py --release v1.0.12
- Flash a specific release tag.
+ Flash a specific Beta release tag.
python flash.py --full
Do a full flash with the merged binary.
python flash.py --offline
@@ -1245,7 +1255,7 @@ Examples:
parser.add_argument("--port", "-p", help="Serial port (auto-detected if omitted)")
parser.add_argument("--baud", "-b", default=None, help="Baud rate (board-specific default)")
parser.add_argument("--release", "-r", default=None, metavar="TAG",
- help="Flash a specific release version (e.g. v1.0.12)")
+ help="Flash a specific Beta release version (e.g. v1.0.12)")
parser.add_argument("--update", action="store_true",
help="Legacy alias for an app-only firmware update")
parser.add_argument("--offline", action="store_true",
@@ -1288,7 +1298,7 @@ Examples:
# Explicit board — keep the selected profile, but still probe the device
# when a port is available so flash size can override stale profile data.
_board = args.board
- _early_port = args.port or find_serial_port()
+ _early_port = None if args.merge_only else (args.port or find_serial_port())
if _early_port:
print(f"Reading flash info from {_early_port}...")
info, err = read_flash_info(_early_port, esptool_cmd)
@@ -1360,6 +1370,7 @@ Examples:
print(f" Variant: {fv['firmware_bin']}")
print(f" Flash mode: {BOARD_FLASH_MODE().upper()}"
+ (" (override)" if _flash_mode_override else " (board default)"))
+ print(f" GitHub releases: {RELEASE_CHANNEL} ({RELEASE_NOTE})")
# Determine firmware file
firmware_path = None
@@ -1434,7 +1445,7 @@ Examples:
fw_path, tag_or_err = fetch_firmware(_board, FLASH_SIZE(), release_tag=args.release)
if fw_path:
firmware_path = fw_path
- print(f"\n Release: {tag_or_err}")
+ print(f"\n Release: {_format_release_label(tag_or_err)}")
else:
print(f"\n GitHub: {tag_or_err}")
print(" Falling back to local firmware...")

diff --git a/lib/microReticulum/src/Destination.cpp b/lib/microReticulum/src/Destination.cpp
index d15a460..5674bf9 100755
--- a/lib/microReticulum/src/Destination.cpp
+++ b/lib/microReticulum/src/Destination.cpp
@@ -252,9 +252,11 @@ Packet Destination::announce(const Bytes& app_data, bool path_response, const In
}
else {
Bytes destination_hash = _object->_hash;
- //p random_hash = Identity::get_random_hash()[0:5] << int(time.time()).to_bytes(5, "big")
- // CBA TODO add in time to random hash
- Bytes random_hash = Cryptography::random(Type::Identity::RANDOM_HASH_LENGTH/8);
+ Bytes random_hash = Cryptography::random(5);
+ uint64_t emitted = (uint64_t)OS::time();
+ for (int shift = 32; shift >= 0; shift -= 8) {
+ random_hash << (uint8_t)((emitted >> shift) & 0xFF);
+ }
Bytes new_app_data(app_data);
if (new_app_data.empty() && !_object->_default_app_data.empty()) {

diff --git a/lib/microReticulum/src/Packet.h b/lib/microReticulum/src/Packet.h
index c6898ef..08ad797 100755
--- a/lib/microReticulum/src/Packet.h
+++ b/lib/microReticulum/src/Packet.h
@@ -258,7 +258,13 @@ namespace RNS {
inline void sent(bool sent) { assert(_object); _object->_sent = sent; }
inline void sent_at(double sent_at) { assert(_object); _object->_sent_at = sent_at; }
inline void receipt(const PacketReceipt& receipt) { assert(_object); _object->_receipt = receipt; }
- inline void hops(uint8_t hops) { assert(_object); _object->_hops = hops; }
+ inline void hops(uint8_t hops) {
+ assert(_object);
+ _object->_hops = hops;
+ if (_object->_raw.size() > 1) {
+ _object->_raw[1] = hops;
+ }
+ }
inline void cached(bool cached) { assert(_object); _object->_cached = cached; }
inline void transport_id(const Bytes& transport_id) { assert(_object); _object->_transport_id = transport_id; }
//CBA Following method is only used by Link to provide Resource access to decrypted resource advertisement. Consider a better way.

diff --git a/lib/microReticulum/src/Transport.cpp b/lib/microReticulum/src/Transport.cpp
index f4b1ce9..3ec7e2f 100755
--- a/lib/microReticulum/src/Transport.cpp
+++ b/lib/microReticulum/src/Transport.cpp
@@ -57,10 +57,11 @@ using namespace RNS::Utilities;
///*static*/ std::set<Interface> Transport::_local_client_interfaces;
/*static*/ std::set<std::reference_wrapper<const Interface>, std::less<const Interface>> Transport::_local_client_interfaces;
-/*static*/ std::map<Bytes, const Interface&> Transport::_pending_local_path_requests;
+/*static*/ std::map<Bytes, Bytes> Transport::_pending_local_path_requests;
// CBA
/*static*/ std::map<Bytes, Transport::PacketEntry> Transport::_packet_table;
+/*static*/ std::set<Bytes> Transport::_known_cached_packet_hashes;
/*static*/ uint16_t Transport::_LOCAL_CLIENT_CACHE_MAXSIZE = 512;
@@ -101,9 +102,10 @@ using namespace RNS::Utilities;
/*static*/ Reticulum Transport::_owner({Type::NONE});
-// FIREWALL MODE Whitelist 1: addresses of local devices (from LoRa and LocalTCP interfaces)
+// FIREWALL MODE Whitelist 1: addresses of trusted local devices.
static std::set<Bytes> _boundary_local_addresses;
-// FIREWALL MODE Whitelist 2: addresses mentioned in packets from local devices
+// FIREWALL MODE Whitelist 2: addresses mentioned by trusted local devices
+// or by already-allowed return traffic.
static std::set<Bytes> _boundary_mentioned_addresses;
static const uint16_t _boundary_maxsize = 200;
@@ -111,6 +113,20 @@ static const uint16_t _boundary_maxsize = 200;
static bool is_backbone_interface(const Interface& iface) {
return iface.is_backbone();
}
+
+#ifdef FIREWALL_MODE
+static bool is_boundary_trusted_interface(const Interface& iface) {
+ return Transport::is_local_client_interface(iface);
+}
+
+static bool is_boundary_untrusted_interface(const Interface& iface) {
+ return !is_boundary_trusted_interface(iface);
+}
+
+static bool is_boundary_address_packet(const Packet& packet) {
+ return packet.destination_type() == Type::Destination::SINGLE && packet.packet_type() != Type::Packet::PROOF;
+}
+#endif
/*static*/ Identity Transport::_identity({Type::NONE});
// CBA
@@ -344,6 +360,12 @@ static bool is_backbone_interface(const Interface& iface) {
// Process announces needing retransmission
if (OS::time() > (_announces_last_checked + _announces_check_interval)) {
DEBUG("DIAG: ANNOUNCE-TBL size=" + std::to_string(_announce_table.size()));
+#ifdef FIREWALL_MODE
+ while (_announce_table.size() > 8) {
+ DEBUG("BOUNDARY: Culling queued announce to protect heap (annc=" + std::to_string(_announce_table.size()) + ")");
+ _announce_table.erase(_announce_table.begin());
+ }
+#endif
//p for destination_hash in Transport.announce_table:
for (auto& [destination_hash, announce_entry] : _announce_table) {
//for (auto& pair : _announce_table) {
@@ -465,10 +487,8 @@ static bool is_backbone_interface(const Interface& iface) {
}
// Cull the packet hashlist if it has reached its max size
- if (_packet_hashlist.size() > _hashlist_maxsize) {
- std::set<Bytes>::iterator iter = _packet_hashlist.begin();
- std::advance(iter, _packet_hashlist.size() - _hashlist_maxsize);
- _packet_hashlist.erase(_packet_hashlist.begin(), iter);
+ while (_packet_hashlist.size() > _hashlist_maxsize) {
+ _packet_hashlist.erase(_packet_hashlist.begin());
}
#ifdef FIREWALL_MODE
@@ -600,6 +620,10 @@ static bool is_backbone_interface(const Interface& iface) {
stale_paths.push_back(destination_hash);
DEBUG("Path to " + destination_hash.toHex() + " timed out and was removed");
}
+ else if (!attached_interface) {
+ stale_paths.push_back(destination_hash);
+ DEBUG("Path to " + destination_hash.toHex() + " was removed since the attached interface is missing");
+ }
else if (_interfaces.count(attached_interface.get_hash()) == 0) {
stale_paths.push_back(destination_hash);
DEBUG("Path to " + destination_hash.toHex() + " was removed since the attached interface no longer exists");
@@ -631,8 +655,8 @@ static bool is_backbone_interface(const Interface& iface) {
// Cull pending local path requests for interfaces that no longer exist
{
std::vector<Bytes> stale_plpr;
- for (const auto& [destination_hash, iface] : _pending_local_path_requests) {
- if (_interfaces.count(iface.get_hash()) == 0) {
+ for (const auto& [destination_hash, iface_hash] : _pending_local_path_requests) {
+ if (!iface_hash || _interfaces.count(iface_hash) == 0) {
stale_plpr.push_back(destination_hash);
}
}
@@ -1132,6 +1156,9 @@ static bool is_backbone_interface(const Interface& iface) {
if (!stored_hash) {
// CBA ACCUMULATES
_packet_hashlist.insert(packet.packet_hash());
+ while (_packet_hashlist.size() > _hashlist_maxsize) {
+ _packet_hashlist.erase(_packet_hashlist.begin());
+ }
stored_hash = true;
}
@@ -1388,6 +1415,14 @@ static bool is_backbone_interface(const Interface& iface) {
packet.receiving_interface(interface);
packet.hops(packet.hops() + 1);
+#ifdef FIREWALL_MODE
+ VERBOSEF("[PKT] IN iface=%s sz=%u type=%u ctx=%u hdr=%u dstt=%u hops=%u dst=%s tid=%s",
+ interface.toString().c_str(), (unsigned)raw.size(), (unsigned)packet.packet_type(),
+ (unsigned)packet.context(), (unsigned)packet.header_type(), (unsigned)packet.destination_type(),
+ (unsigned)packet.hops(), packet.destination_hash().toHex().substr(0,8).c_str(),
+ packet.transport_id() ? packet.transport_id().toHex().substr(0,8).c_str() : "none");
+#endif
+
// TODO
/*p
if (interface) {
@@ -1434,18 +1469,23 @@ static bool is_backbone_interface(const Interface& iface) {
if (accept) {
accept = packet_filter(packet);
}
+ else {
+#ifdef FIREWALL_MODE
+ VERBOSEF("[PKT] DROP callback dst=%s type=%u ctx=%u",
+ packet.destination_hash().toHex().substr(0,8).c_str(),
+ (unsigned)packet.packet_type(), (unsigned)packet.context());
+#endif
+ }
if (accept) {
TRACE("Transport::inbound: Packet accepted by filter");
- // FIREWALL MODE: Comprehensive firewall for backbone traffic.
+ // FIREWALL MODE: Comprehensive firewall for untrusted ingress.
//
// Three rules:
- // 1. Addresses that touch local interfaces (RNode/LoRa, LocalTCP)
- // get whitelisted on the backbone interface.
- // 2. Every packet referencing a whitelisted address — ALL identifiers
- // in that packet also get whitelisted (link hashes, announces,
- // requests, proofs, truncated hashes, transport IDs, EVERYTHING).
- // 3. Everything else gets blocked on the backbone interface.
+ // 1. Addresses that touch trusted local interfaces get whitelisted.
+ // 2. Allowed traffic can only extend the two address whitelists with
+ // destination addresses, never per-packet or per-link identifiers.
+ // 3. Everything else from untrusted ingress gets blocked.
//
// Note on ratchets: ratchet public keys are embedded in announce
// payloads and flow through unchanged since we forward the entire
@@ -1454,9 +1494,25 @@ static bool is_backbone_interface(const Interface& iface) {
// handling is needed here.
#ifdef FIREWALL_MODE
{
- bool is_backbone = is_backbone_interface(packet.receiving_interface());
- if (is_backbone) {
- // === BACKBONE PACKET: gate against all whitelists ===
+ bool is_untrusted_ingress = is_boundary_untrusted_interface(packet.receiving_interface());
+ bool is_announce = packet.packet_type() == Type::Packet::ANNOUNCE;
+ if (is_untrusted_ingress) {
+ if (is_announce) {
+ // Real Reticulum destinations answer path requests with a
+ // PATH_RESPONSE announce using the normal announce packet
+ // header shape. Only transport-rebroadcasted responses carry
+ // HEADER_2 and our transport identity. Treat any matching
+ // PATH_RESPONSE for an outstanding discovery request as solicited.
+ bool solicited_path_response = packet.context() == Type::Packet::PATH_RESPONSE
+ && _discovery_path_requests.find(packet.destination_hash()) != _discovery_path_requests.end();
+
+ if (!solicited_path_response) {
+ DEBUG("BOUNDARY: BLOCKED unsolicited backbone announce dest=" + packet.destination_hash().toHex().substr(0,8) + " ctx=" + std::to_string(packet.context()) + " hdr=" + std::to_string(packet.header_type()));
+ return;
+ }
+ }
+
+ // === UNTRUSTED PACKET: gate against all whitelists ===
bool allowed = false;
// Whitelist 1: destination is a local device
if (_boundary_local_addresses.find(packet.destination_hash()) != _boundary_local_addresses.end()) {
@@ -1494,33 +1550,23 @@ static bool is_backbone_interface(const Interface& iface) {
return;
}
// === TRANSITIVE WHITELIST ===
- // Extract ALL identifiers from this allowed backbone packet
- // so that future related traffic (proofs, link data, return
- // packets) will also pass through the filter.
- _boundary_mentioned_addresses.insert(packet.destination_hash());
- if (packet.header_type() == Type::Packet::HEADER_2 && packet.transport_id()) {
- _boundary_mentioned_addresses.insert(packet.transport_id());
- }
- if (packet.packet_type() == Type::Packet::LINKREQUEST) {
- _boundary_mentioned_addresses.insert(Link::link_id_from_lr_packet(packet));
+ // Keep the firewall state to address whitelists only.
+ if (is_boundary_address_packet(packet)) {
+ _boundary_mentioned_addresses.insert(packet.destination_hash());
}
- _boundary_mentioned_addresses.insert(packet.getTruncatedHash());
}
else {
- // === LOCAL DEVICE PACKET ===
- // Whitelist ALL identifiers from this packet so future
- // related backbone traffic will be allowed through.
- // Every identifier that touches a local interface gets
- // whitelisted on the backbone — link hashes, announces,
- // requests, proofs, EVERYTHING.
- _boundary_mentioned_addresses.insert(packet.destination_hash());
- if (packet.header_type() == Type::Packet::HEADER_2 && packet.transport_id()) {
- _boundary_mentioned_addresses.insert(packet.transport_id());
+ if (is_announce) {
+ _boundary_local_addresses.insert(packet.destination_hash());
+ }
+ else {
+ // === TRUSTED LOCAL PACKET ===
+ // Only whitelist destination addresses learned from trusted
+ // local interfaces.
+ if (is_boundary_address_packet(packet)) {
+ _boundary_mentioned_addresses.insert(packet.destination_hash());
}
- if (packet.packet_type() == Type::Packet::LINKREQUEST) {
- _boundary_mentioned_addresses.insert(Link::link_id_from_lr_packet(packet));
}
- _boundary_mentioned_addresses.insert(packet.getTruncatedHash());
}
}
#endif
@@ -1559,6 +1605,9 @@ static bool is_backbone_interface(const Interface& iface) {
if (remember_packet_hash) {
// CBA ACCUMULATES
_packet_hashlist.insert(packet.packet_hash());
+ while (_packet_hashlist.size() > _hashlist_maxsize) {
+ _packet_hashlist.erase(_packet_hashlist.begin());
+ }
}
cache_packet(packet);
@@ -1573,7 +1622,7 @@ static bool is_backbone_interface(const Interface& iface) {
// Check special conditions for local clients connected
// through a shared Reticulum instance
//p from_local_client = (packet.receiving_interface in Transport.local_client_interfaces)
- bool from_local_client = (_local_client_interfaces.find(packet.receiving_interface()) != _local_client_interfaces.end());
+ bool from_local_client = is_local_client_interface(packet.receiving_interface());
//p for_local_client = (packet.packet_type != RNS.Packet.ANNOUNCE) and (packet.destination_hash in Transport.destination_table and Transport.destination_table[packet.destination_hash][2] == 0)
//p for_local_client_link = (packet.packet_type != RNS.Packet.ANNOUNCE) and (packet.destination_hash in Transport.link_table and Transport.link_table[packet.destination_hash][4] in Transport.local_client_interfaces)
//p for_local_client_link |= (packet.packet_type != RNS.Packet.ANNOUNCE) and (packet.destination_hash in Transport.link_table and Transport.link_table[packet.destination_hash][2] in Transport.local_client_interfaces)
@@ -1593,11 +1642,11 @@ static bool is_backbone_interface(const Interface& iface) {
auto link_iter = _link_table.find(packet.destination_hash());
if (link_iter != _link_table.end()) {
LinkEntry link_entry = (*link_iter).second;
- if (_local_client_interfaces.find(link_entry._receiving_interface) != _local_client_interfaces.end()) {
+ if (is_local_client_interface(link_entry._receiving_interface)) {
// Destined for a local link
for_local_client_link = true;
}
- if (_local_client_interfaces.find(link_entry._outbound_interface) != _local_client_interfaces.end()) {
+ if (is_local_client_interface(link_entry._outbound_interface)) {
// Destined for a local link
for_local_client_link = true;
}
@@ -1610,7 +1659,7 @@ static bool is_backbone_interface(const Interface& iface) {
auto reverse_iter = _reverse_table.find(packet.destination_hash());
if (reverse_iter != _reverse_table.end()) {
ReverseEntry reverse_entry = (*reverse_iter).second;
- if (_local_client_interfaces.find(reverse_entry._receiving_interface) != _local_client_interfaces.end()) {
+ if (is_local_client_interface(reverse_entry._receiving_interface)) {
// Proof for local destination???
proof_for_local_client = true;
}
@@ -1684,6 +1733,12 @@ static bool is_backbone_interface(const Interface& iface) {
TRACE("Transport::inbound: Cached packet");
return;
}
+#ifdef FIREWALL_MODE
+ if (is_backbone_interface(packet.receiving_interface())) {
+ TRACE("BOUNDARY: Dropping unsatisfied backbone cache request");
+ return;
+ }
+#endif
}
// If the packet is in transport, check whether we
@@ -1730,7 +1785,7 @@ static bool is_backbone_interface(const Interface& iface) {
//new_raw = packet.raw[0:1]
new_raw << packet.raw().left(1);
//new_raw += struct.pack("!B", packet.hops)
- new_raw << packet.hops();
+ new_raw << (is_local_client_interface(destination_entry.receiving_interface()) ? (uint8_t)0 : packet.hops());
//new_raw += packet.raw[2:]
new_raw << packet.raw().mid(2);
}
@@ -1792,6 +1847,11 @@ static bool is_backbone_interface(const Interface& iface) {
);
// CBA ACCUMULATES
_link_table.insert({Link::link_id_from_lr_packet(packet), link_entry});
+ VERBOSEF("[LINK] CREATE dst=%s link=%s rem=%u recv=%s out=%s lt=%u",
+ packet.destination_hash().toHex().substr(0,8).c_str(),
+ Link::link_id_from_lr_packet(packet).toHex().substr(0,8).c_str(),
+ (unsigned)remaining_hops, packet.receiving_interface().toString().c_str(),
+ outbound_interface.toString().c_str(), (unsigned)_link_table.size());
}
else {
TRACE("Transport::inbound: Packet is next-hop other type");
@@ -1923,6 +1983,11 @@ static bool is_backbone_interface(const Interface& iface) {
);
// Each LINKREQUEST gets its own entry (unique link_id)
_link_table.insert({Link::link_id_from_lr_packet(packet), link_entry});
+ VERBOSEF("[LINK] CREATE local dst=%s link=%s rem=%u recv=%s out=%s lt=%u",
+ packet.destination_hash().toHex().substr(0,8).c_str(),
+ Link::link_id_from_lr_packet(packet).toHex().substr(0,8).c_str(),
+ (unsigned)remaining_hops, packet.receiving_interface().toString().c_str(),
+ outbound_interface.toString().c_str(), (unsigned)_link_table.size());
}
else {
ReverseEntry reverse_entry(
@@ -2007,6 +2072,11 @@ static bool is_backbone_interface(const Interface& iface) {
);
_link_table.insert({Link::link_id_from_lr_packet(packet), link_entry});
DEBUG("BOUNDARY: Created link_table entry for backbone LINKREQUEST, link_id=" + Link::link_id_from_lr_packet(packet).toHex());
+ VERBOSEF("[LINK] CREATE backbone dst=%s link=%s rem=%u recv=%s out=%s lt=%u",
+ packet.destination_hash().toHex().substr(0,8).c_str(),
+ Link::link_id_from_lr_packet(packet).toHex().substr(0,8).c_str(),
+ (unsigned)remaining_hops, packet.receiving_interface().toString().c_str(),
+ outbound_interface.toString().c_str(), (unsigned)_link_table.size());
}
else {
ReverseEntry reverse_entry(
@@ -2034,6 +2104,11 @@ static bool is_backbone_interface(const Interface& iface) {
DEBUG("LINK-XPORT: pkt for " + packet.destination_hash().toHex().substr(0,8) + " type=" + std::to_string(packet.packet_type()) + " ctx=" + std::to_string(packet.context()) + " hops=" + std::to_string(packet.hops()) + " from=" + packet.receiving_interface().toString() + " hdr=" + std::to_string(packet.header_type()) + " sz=" + std::to_string(packet.raw().size()));
LinkEntry& link_entry = (*link_iter).second;
DEBUG("LINK-XPORT: entry hops=" + std::to_string(link_entry._hops) + " rem=" + std::to_string(link_entry._remaining_hops) + " recv=" + link_entry._receiving_interface.toString() + " out=" + link_entry._outbound_interface.toString() + " val=" + std::to_string(link_entry._validated));
+ VERBOSEF("[LINK] XPORT pkt=%s type=%u ctx=%u hops=%u recv=%s entry_recv=%s entry_out=%s rem=%u taken=%u val=%u",
+ packet.destination_hash().toHex().substr(0,8).c_str(), (unsigned)packet.packet_type(),
+ (unsigned)packet.context(), (unsigned)packet.hops(), packet.receiving_interface().toString().c_str(),
+ link_entry._receiving_interface.toString().c_str(), link_entry._outbound_interface.toString().c_str(),
+ (unsigned)link_entry._remaining_hops, (unsigned)link_entry._hops, link_entry._validated ? 1 : 0);
// If receiving and outbound interface is
// the same for this link, direction doesn't
// matter, and we simply send the packet on.
@@ -2075,10 +2150,14 @@ static bool is_backbone_interface(const Interface& iface) {
if (outbound_interface) {
DEBUG("LINK-XPORT: FWD to " + outbound_interface.toString());
+ VERBOSEF("[LINK] FWD dst=%s to=%s size=%u", packet.destination_hash().toHex().substr(0,8).c_str(), outbound_interface.toString().c_str(), (unsigned)packet.raw().size());
// Add this packet to the filter hashlist now that
// we have determined it's actually our turn to
// process it (matching Python Transport line 1544).
_packet_hashlist.insert(packet.packet_hash());
+ while (_packet_hashlist.size() > _hashlist_maxsize) {
+ _packet_hashlist.erase(_packet_hashlist.begin());
+ }
// CBA RESERVE
//Bytes new_raw;
Bytes new_raw(512);
@@ -2093,6 +2172,7 @@ static bool is_backbone_interface(const Interface& iface) {
}
else {
DEBUG("LINK-XPORT: DROPPED (no outbound interface resolved)");
+ VERBOSEF("[LINK] DROP dst=%s recv=%s rem=%u taken=%u pkt_hops=%u", packet.destination_hash().toHex().substr(0,8).c_str(), packet.receiving_interface().toString().c_str(), (unsigned)link_entry._remaining_hops, (unsigned)link_entry._hops, (unsigned)packet.hops());
}
}
}
@@ -2109,6 +2189,11 @@ static bool is_backbone_interface(const Interface& iface) {
TRACE("Transport::inbound: Packet is ANNOUNCE");
DEBUG("DIAG: ANNOUNCE-IN dest=" + packet.destination_hash().toHex().substr(0,8) + " iface=" + packet.receiving_interface().toString() + " hops=" + std::to_string(packet.hops()));
Bytes received_from;
+ bool announce_valid = Identity::validate_announce(packet);
+ VERBOSEF("[ANNC] IN dst=%s valid=%u ctx=%u hops=%u iface=%s data=%u",
+ packet.destination_hash().toHex().substr(0,8).c_str(), announce_valid ? 1 : 0,
+ (unsigned)packet.context(), (unsigned)packet.hops(),
+ packet.receiving_interface().toString().c_str(), (unsigned)packet.data().size());
//p local_destination = next((d for d in Transport.destinations if d.hash == packet.destination_hash), None)
#if defined(DESTINATIONS_SET)
//Destination local_destination({Type::NONE});
@@ -2122,10 +2207,10 @@ static bool is_backbone_interface(const Interface& iface) {
}
//if local_destination == None and RNS.Identity.validate_announce(packet):
//if (!local_destination && Identity::validate_announce(packet)) {
- if (!found_local && Identity::validate_announce(packet)) {
+ if (!found_local && announce_valid) {
#elif defined(DESTINATIONS_MAP)
auto iter = _destinations.find(packet.destination_hash());
- if (iter == _destinations.end() && Identity::validate_announce(packet)) {
+ if (iter == _destinations.end() && announce_valid) {
#endif
TRACE("Transport::inbound: Packet is announce for non-local destination, processing...");
if (packet.transport_id()) {
@@ -2363,6 +2448,11 @@ static bool is_backbone_interface(const Interface& iface) {
if (rate_blocked) {
DEBUG("Blocking rebroadcast of announce from " + packet.destination_hash().toHex() + " due to excessive announce rate");
}
+#ifdef FIREWALL_MODE
+ else if (is_backbone_interface(packet.receiving_interface())) {
+ DEBUG("BOUNDARY: Suppressing announce rebroadcast queue for " + packet.destination_hash().toHex().substr(0,8));
+ }
+#endif
else {
if (Transport::from_local_client(packet)) {
// If the announce is from a local client,
@@ -2396,26 +2486,57 @@ static bool is_backbone_interface(const Interface& iface) {
auto iter = _pending_local_path_requests.find(packet.destination_hash());
if (iter != _pending_local_path_requests.end()) {
//p desiring_interface = Transport.pending_local_path_requests.pop(packet.destination_hash)
- //const Interface& desiring_interface = (*iter).second;
+ Interface desiring_interface = find_interface_from_hash((*iter).second);
_pending_local_path_requests.erase(iter); // CBA FIX: pop() equivalent
- retransmit_timeout = now;
- retries = PATHFINDER_R;
+ if (desiring_interface) {
+ attached_interface = desiring_interface;
+ retransmit_timeout = now;
+ retries = PATHFINDER_R;
- AnnounceEntry announce_entry(
- now,
- retransmit_timeout,
- retries,
- received_from,
- announce_hops,
- packet,
- local_rebroadcasts,
- block_rebroadcasts,
- attached_interface
- );
- // BUG FIX: erase before insert since std::map::insert() is
- // a no-op when key exists (Python dict assignment overwrites)
- _announce_table.erase(packet.destination_hash());
- _announce_table.insert({packet.destination_hash(), announce_entry});
+ AnnounceEntry announce_entry(
+ now,
+ retransmit_timeout,
+ retries,
+ received_from,
+ announce_hops,
+ packet,
+ local_rebroadcasts,
+ block_rebroadcasts,
+ attached_interface
+ );
+ // BUG FIX: erase before insert since std::map::insert() is
+ // a no-op when key exists (Python dict assignment overwrites)
+ _announce_table.erase(packet.destination_hash());
+ _announce_table.insert({packet.destination_hash(), announce_entry});
+
+#ifdef FIREWALL_MODE
+ Identity announce_identity(Identity::recall(packet.destination_hash()));
+ if (announce_identity && !is_backbone_interface(attached_interface)) {
+ Destination announce_destination(announce_identity, Type::Destination::OUT, Type::Destination::SINGLE, packet.destination_hash());
+ Packet path_response(
+ announce_destination,
+ attached_interface,
+ packet.data(),
+ Type::Packet::ANNOUNCE,
+ Type::Packet::PATH_RESPONSE,
+ Type::Transport::TRANSPORT,
+ Type::Packet::HEADER_2,
+ _identity.hash(),
+ true,
+ packet.context_flag()
+ );
+ path_response.hops(packet.hops());
+ path_response.send();
+ _announce_table.erase(packet.destination_hash());
+ VERBOSEF("[PATH] RESP local-client dst=%s hops=%u to=%s",
+ packet.destination_hash().toHex().substr(0,8).c_str(),
+ (unsigned)packet.hops(), attached_interface.toString().c_str());
+ }
+#endif
+ }
+ else {
+ DEBUG("Dropping local-client path response for " + packet.destination_hash().toHex().substr(0,8) + ", pending requester interface disappeared");
+ }
}
}
@@ -2507,6 +2628,7 @@ static bool is_backbone_interface(const Interface& iface) {
new_announce.hops(packet.hops());
new_announce.send();
+ _discovery_path_requests.erase(iter);
}
// CBA Culling before adding to esnure table does not exceed maxsize
@@ -2540,15 +2662,18 @@ static bool is_backbone_interface(const Interface& iface) {
cull_path_table();
}
}
+ VERBOSEF("[PATH] STORED dst=%s hops=%u iface=%s paths=%u bla=%u",
+ packet.destination_hash().toHex().substr(0,8).c_str(), (unsigned)announce_hops,
+ packet.receiving_interface().toString().c_str(), (unsigned)_destination_table.size(),
+ (unsigned)_boundary_local_addresses.size());
DEBUG("Destination " + packet.destination_hash().toHex() + " is now " + std::to_string(announce_hops) + " hops away via " + received_from.toHex() + " on " + packet.receiving_interface().toString());
DEBUG("DIAG: STORED path " + packet.destination_hash().toHex().substr(0,8) + " hops=" + std::to_string(announce_hops) + " iface=" + packet.receiving_interface().toString());
- // FIREWALL MODE: Register destinations seen via non-backbone interfaces (Whitelist 1)
+ // FIREWALL MODE: Register destinations seen via trusted local interfaces (Whitelist 1)
#ifdef FIREWALL_MODE
{
- bool is_backbone = is_backbone_interface(packet.receiving_interface());
- if (!is_backbone) {
+ if (is_boundary_trusted_interface(packet.receiving_interface())) {
_boundary_local_addresses.insert(packet.destination_hash());
DEBUG("BOUNDARY: Registered local address " + packet.destination_hash().toHex() + " from local interface");
}
@@ -2616,6 +2741,8 @@ static bool is_backbone_interface(const Interface& iface) {
}
else {
TRACE("Transport::inbound: Packet is announce for local destination, not processing");
+ VERBOSEF("[ANNC] SKIP dst=%s valid=%u reason=local_or_invalid",
+ packet.destination_hash().toHex().substr(0,8).c_str(), announce_valid ? 1 : 0);
}
}
@@ -2714,6 +2841,11 @@ static bool is_backbone_interface(const Interface& iface) {
TRACE("Transport::inbound: Packet is PROOF");
if (packet.context() == Type::Packet::LRPROOF) {
TRACE("Transport::inbound: Packet is LINK PROOF");
+ VERBOSEF("[LRPROOF] IN dst=%s hops=%u recv=%s in_lt=%u for_lcl=%u from_lcl=%u",
+ packet.destination_hash().toHex().substr(0,8).c_str(), (unsigned)packet.hops(),
+ packet.receiving_interface().toString().c_str(),
+ _link_table.find(packet.destination_hash()) != _link_table.end() ? 1 : 0,
+ for_local_client_link ? 1 : 0, from_local_client ? 1 : 0);
// This is a link request proof, check if it
// needs to be transported
if ((Reticulum::transport_enabled() || for_local_client_link || from_local_client) && _link_table.find(packet.destination_hash()) != _link_table.end()) {
@@ -2747,6 +2879,7 @@ static bool is_backbone_interface(const Interface& iface) {
if (peer_identity.validate(signature, signed_data)) {
DEBUG("LRPROOF-XPORT: VALIDATED, forwarding to " + link_entry._receiving_interface.toString());
+ VERBOSEF("[LRPROOF] FWD dst=%s to=%s size=%u", packet.destination_hash().toHex().substr(0,8).c_str(), link_entry._receiving_interface.toString().c_str(), (unsigned)packet.raw().size());
//p new_raw = packet.raw[0:1]
// CBA RESERVE
//Bytes new_raw = packet.raw().left(1);
@@ -2763,11 +2896,13 @@ static bool is_backbone_interface(const Interface& iface) {
}
else {
DEBUG("LRPROOF-XPORT: INVALID signature for link " + packet.destination_hash().toHex().substr(0,8) + ", dropping proof.");
+ VERBOSEF("[LRPROOF] DROP invalid-signature dst=%s", packet.destination_hash().toHex().substr(0,8).c_str());
}
} // end peer_identity valid
}
else {
DEBUG("LRPROOF-XPORT: UNEXPECTED data_size=" + std::to_string(packet.data().size()) + " (expected " + std::to_string(expected_size) + " or " + std::to_string(expected_size_with_mtu) + "), dropping proof.");
+ VERBOSEF("[LRPROOF] DROP bad-size dst=%s data=%u expected=%u/%u", packet.destination_hash().toHex().substr(0,8).c_str(), (unsigned)packet.data().size(), (unsigned)expected_size, (unsigned)expected_size_with_mtu);
}
}
catch (std::exception& e) {
@@ -2777,12 +2912,14 @@ static bool is_backbone_interface(const Interface& iface) {
else {
DEBUG("LRPROOF-XPORT: IFACE MISMATCH recv=" + packet.receiving_interface().toString() + " expected_out=" + link_entry._outbound_interface.toString());
DEBUG("LRPROOF-XPORT: Proof received on wrong interface, not transporting.");
+ VERBOSEF("[LRPROOF] DROP iface-mismatch dst=%s recv=%s expected=%s", packet.destination_hash().toHex().substr(0,8).c_str(), packet.receiving_interface().toString().c_str(), link_entry._outbound_interface.toString().c_str());
}
}
else {
// Not in link_table or transport not enabled — check
// if we can deliver it to a local pending link
DEBUG("LRPROOF-XPORT: not in link_table or transport not enabled, checking local pending links (transport=" + std::to_string(Reticulum::transport_enabled()) + " for_lcl=" + std::to_string(for_local_client_link) + " from_lcl=" + std::to_string(from_local_client) + " in_lt=" + std::to_string(_link_table.find(packet.destination_hash()) != _link_table.end()) + ")");
+ VERBOSEF("[LRPROOF] LOCAL-CHECK dst=%s pending=%u", packet.destination_hash().toHex().substr(0,8).c_str(), (unsigned)_pending_links.size());
// CBA Must make a copy of _pending_links before traversing since it gets modified
//for (auto link : _pending_links) {
std::set<Link> pending_links(_pending_links);
@@ -2825,6 +2962,10 @@ static bool is_backbone_interface(const Interface& iface) {
ReverseEntry reverse_entry = (*_reverse_table.find(packet.destination_hash())).second;
if (packet.receiving_interface() == reverse_entry._outbound_interface) {
TRACE("Proof received on correct interface, transporting it via " + reverse_entry._receiving_interface.toString());
+ VERBOSEF("[PROOF] XPORT dst=%s data=%u hops=%u recv=%s out=%s",
+ packet.destination_hash().toHex().substr(0,8).c_str(), (unsigned)packet.data().size(),
+ (unsigned)packet.hops(), packet.receiving_interface().toString().c_str(),
+ reverse_entry._receiving_interface.toString().c_str());
//p new_raw = packet.raw[0:1]
// CBA RESERVE
//Bytes new_raw = packet.raw().left(1);
@@ -2838,10 +2979,12 @@ static bool is_backbone_interface(const Interface& iface) {
}
else {
DEBUG("Proof received on wrong interface, not transporting it.");
+ VERBOSEF("[PROOF] DROP iface-mismatch dst=%s recv=%s expected=%s", packet.destination_hash().toHex().substr(0,8).c_str(), packet.receiving_interface().toString().c_str(), reverse_entry._outbound_interface.toString().c_str());
}
}
else {
TRACE("Proof is not candidate for transporting");
+ VERBOSEF("[PROOF] LOCAL dst=%s in_rev=%u from_lcl=%u proof_lcl=%u", packet.destination_hash().toHex().substr(0,8).c_str(), _reverse_table.find(packet.destination_hash()) != _reverse_table.end() ? 1 : 0, from_local_client ? 1 : 0, proof_for_local_client ? 1 : 0);
}
std::list<PacketReceipt> cull_receipts;
@@ -3205,24 +3348,63 @@ Deregisters an announce handler.
// the packet cache.
/*static*/ bool Transport::cache_packet(const Packet& packet, bool force_cache /*= false*/) {
TRACE("Checking to see if packet " + packet.get_hash().toHex() + " should be cached");
-#if defined(RNS_USE_FS) && defined(RNS_PERSIST_PATHS)
if (should_cache_packet(packet) || force_cache) {
+#ifdef FIREWALL_MODE
+ Bytes packet_hash = packet.get_hash();
+ _packet_table.erase(packet_hash);
+ _packet_table.insert({packet_hash, PacketEntry(packet)});
+ _known_cached_packet_hashes.insert(packet_hash);
+ while (_packet_table.size() > _path_table_maxsize) {
+ auto oldest = _packet_table.begin();
+ for (auto iter = _packet_table.begin(); iter != _packet_table.end(); ++iter) {
+ if ((*iter).second._sent_at < (*oldest).second._sent_at) {
+ oldest = iter;
+ }
+ }
+ _known_cached_packet_hashes.erase((*oldest).first);
+ _packet_table.erase(oldest);
+ }
+ return true;
+#else
+#if defined(RNS_USE_FS) && defined(RNS_PERSIST_PATHS)
TRACE("Saving packet " + packet.get_hash().toHex() + " to storage");
try {
char packet_cache_path[Type::Reticulum::FILEPATH_MAXSIZE];
snprintf(packet_cache_path, Type::Reticulum::FILEPATH_MAXSIZE, "%s/%s", Reticulum::_cachepath, packet.get_hash().toHex().c_str());
- return (Persistence::serialize(packet, packet_cache_path) > 0);
+ bool cached = (Persistence::serialize(packet, packet_cache_path) > 0);
+ if (cached) {
+ _known_cached_packet_hashes.insert(packet.get_hash());
+ }
+ return cached;
}
catch (std::exception& e) {
ERROR("Error writing packet to cache. The contained exception was: " + std::string(e.what()));
}
- }
#endif
+#endif
+ }
return false;
}
/*static*/ Packet Transport::get_cached_packet(const Bytes& packet_hash) {
TRACE("Loading packet " + packet_hash.toHex() + " from cache storage");
+#ifdef FIREWALL_MODE
+ auto packet_iter = _packet_table.find(packet_hash);
+ if (packet_iter == _packet_table.end()) {
+ _known_cached_packet_hashes.erase(packet_hash);
+ return {Type::NONE};
+ }
+ Packet packet(Destination({Type::NONE}), (*packet_iter).second._raw);
+ if (packet.unpack()) {
+ packet.receiving_interface((*packet_iter).second._receiving_interface);
+ packet.sent_at((*packet_iter).second._sent_at);
+ packet.cached(true);
+ return packet;
+ }
+ _packet_table.erase(packet_iter);
+ _known_cached_packet_hashes.erase(packet_hash);
+ return {Type::NONE};
+#else
#if defined(RNS_USE_FS) && defined(RNS_PERSIST_PATHS)
try {
/*p
@@ -3245,8 +3427,17 @@ Deregisters an announce handler.
else:
return None
*/
+#ifdef FIREWALL_MODE
+ if (_known_cached_packet_hashes.find(packet_hash) == _known_cached_packet_hashes.end()) {
+ return {Type::NONE};
+ }
+#endif
char packet_cache_path[Type::Reticulum::FILEPATH_MAXSIZE];
snprintf(packet_cache_path, Type::Reticulum::FILEPATH_MAXSIZE, "%s/%s", Reticulum::_cachepath, packet_hash.toHex().c_str());
+ if (!OS::file_exists(packet_cache_path)) {
+ _known_cached_packet_hashes.erase(packet_hash);
+ return {Type::NONE};
+ }
Packet packet({Type::NONE});
if (Persistence::deserialize(packet, packet_cache_path) > 0) {
packet.unpack();
@@ -3259,16 +3450,25 @@ Deregisters an announce handler.
}
#endif
return {Type::NONE};
+#endif
}
/*static*/ bool Transport::clear_cached_packet(const Bytes& packet_hash) {
TRACE("Clearing packet " + packet_hash.toHex() + " from cache storage");
+#ifdef FIREWALL_MODE
+ _packet_table.erase(packet_hash);
+ _known_cached_packet_hashes.erase(packet_hash);
+ return true;
+#else
#if defined(RNS_USE_FS) && defined(RNS_PERSIST_PATHS)
try {
char packet_cache_path[Type::Reticulum::FILEPATH_MAXSIZE];
snprintf(packet_cache_path, Type::Reticulum::FILEPATH_MAXSIZE, "%s/%s", Reticulum::_cachepath, packet_hash.toHex().c_str());
double start_time = OS::time();
bool success = RNS::Utilities::OS::remove_file(packet_cache_path);
+ if (success) {
+ _known_cached_packet_hashes.erase(packet_hash);
+ }
double diff_time = OS::time() - start_time;
if (diff_time < 1.0) {
DEBUG("Remove cached packet in " + std::to_string((int)(diff_time*1000)) + " ms");
@@ -3283,6 +3483,7 @@ Deregisters an announce handler.
}
#endif
return false;
+#endif
}
/*static*/ bool Transport::cache_request_packet(const Packet& packet) {
@@ -3571,6 +3772,11 @@ will announce it.
/*static*/ void Transport::path_request_handler(const Bytes& data, const Packet& packet) {
TRACE("Transport::path_request_handler");
if (data.size() >= 16) { DEBUG("DIAG: PATH-REQ for " + data.left(16).toHex().substr(0,8) + " from " + packet.receiving_interface().toString()); }
+ if (data.size() >= 16) {
+ VERBOSEF("[PATH] REQ dst=%s from=%s local=%u sz=%u",
+ data.left(16).toHex().substr(0,8).c_str(), packet.receiving_interface().toString().c_str(),
+ from_local_client(packet) ? 1 : 0, (unsigned)data.size());
+ }
try {
// If there is at least bytes enough for a destination
// hash in the packet, we assume those bytes are the
@@ -3637,7 +3843,11 @@ will announce it.
std::string interface_str;
if (attached_interface) {
- if (Reticulum::transport_enabled() && (attached_interface.mode() & Interface::DISCOVER_PATHS_FOR) > 0) {
+ bool attached_can_discover = true;
+#ifdef FIREWALL_MODE
+ attached_can_discover = is_boundary_trusted_interface(attached_interface);
+#endif
+ if (Reticulum::transport_enabled() && attached_can_discover && (attached_interface.mode() & Interface::DISCOVER_PATHS_FOR) > 0) {
TRACE("Transport::path_request_handler: interface allows searching for unknown paths");
should_search_for_unknown = true;
}
@@ -3656,7 +3866,7 @@ will announce it.
if (is_local_client_interface(destination_entry.receiving_interface())) {
destination_exists_on_local_client = true;
// CBA ACCUMULATES
- _pending_local_path_requests.insert({destination_hash, attached_interface});
+ _pending_local_path_requests[destination_hash] = attached_interface ? attached_interface.get_hash() : Bytes();
}
}
else {
@@ -3766,38 +3976,60 @@ will announce it.
block_rebroadcasts,
attached_interface
);
- // CBA ACCUMULATES
- _announce_table.insert({announce_packet.destination_hash(), announce_entry});
-
- // ESP32 FIX: For requests from local clients, send the
- // PATH_RESPONSE immediately rather than waiting for the
- // jobs() loop to process the announce_table entry. On the
- // ESP32, continuous TCP backbone data can starve the jobs
- // loop for many seconds, causing path discovery timeouts.
- if (is_from_local_client) {
+ // ESP32/FIREWALL_MODE FIX: For requests from LAN-side interfaces,
+ // send the PATH_RESPONSE immediately rather than waiting for the
+ // jobs() loop to process the announce_table entry. This includes
+ // Local TCP clients and LoRa. Backbone/WAN interfaces remain
+ // suppressed below to preserve the boundary firewall.
+ bool send_immediate_path_response = is_from_local_client;
+#ifdef FIREWALL_MODE
+ if (attached_interface && !is_backbone_interface(attached_interface)) {
+ send_immediate_path_response = true;
+ }
+#endif
+ if (send_immediate_path_response) {
Identity imm_identity(Identity::recall(announce_packet.destination_hash()));
- if (imm_identity) {
- Destination imm_destination(imm_identity, Type::Destination::OUT, Type::Destination::SINGLE, announce_packet.destination_hash());
- Packet imm_packet(
- imm_destination,
- attached_interface,
- announce_packet.data(),
- Type::Packet::ANNOUNCE,
- Type::Packet::PATH_RESPONSE,
- Type::Transport::TRANSPORT,
- Type::Packet::HEADER_2,
- _identity.hash(),
- true,
- announce_packet.context_flag()
- );
- imm_packet.hops(announce_hops);
- imm_packet.send();
- DEBUG("DIAG: PATH-RESP immediate send for " + announce_packet.destination_hash().toHex().substr(0,8) + " hops=" + std::to_string(announce_hops) + " to " + attached_interface.toString());
-
- // Remove from announce_table since we already sent it
- _announce_table.erase(announce_packet.destination_hash());
- }
+ Destination imm_destination(imm_identity, Type::Destination::OUT, Type::Destination::SINGLE, announce_packet.destination_hash());
+ Packet imm_packet(
+ imm_destination,
+ attached_interface,
+ announce_packet.data(),
+ Type::Packet::ANNOUNCE,
+ Type::Packet::PATH_RESPONSE,
+ Type::Transport::TRANSPORT,
+ Type::Packet::HEADER_2,
+ _identity.hash(),
+ true,
+ announce_packet.context_flag()
+ );
+ imm_packet.hops(announce_hops);
+ imm_packet.send();
+ VERBOSEF("[PATH] RESP dst=%s hops=%u to=%s local=%u",
+ announce_packet.destination_hash().toHex().substr(0,8).c_str(),
+ (unsigned)announce_hops, attached_interface.toString().c_str(),
+ is_from_local_client ? 1 : 0);
+ DEBUG("DIAG: PATH-RESP immediate send for " + announce_packet.destination_hash().toHex().substr(0,8) + " hops=" + std::to_string(announce_hops) + " to " + attached_interface.toString());
+
+ // Remove from announce_table since we already sent it
+ _announce_table.erase(announce_packet.destination_hash());
+ }
+#ifdef FIREWALL_MODE
+ if (attached_interface && is_boundary_trusted_interface(attached_interface)) {
+ // Keep a queued local retry even after an immediate send. On
+ // repeated LAN-to-WAN requests the immediate response can arrive
+ // before the fresh local client has fully entered path discovery,
+ // but a scheduled retry one job tick later is accepted.
+ announce_entry._retransmit_timeout = OS::time() + _announces_check_interval;
+ _announce_table.erase(announce_packet.destination_hash());
+ _announce_table.insert({announce_packet.destination_hash(), announce_entry});
+ }
+ else {
+ DEBUG("BOUNDARY: Suppressing queued path-response announce for " + announce_packet.destination_hash().toHex().substr(0,8));
}
+#else
+ // CBA ACCUMULATES
+ _announce_table.insert({announce_packet.destination_hash(), announce_entry});
+#endif
}
}
}
@@ -3806,6 +4038,15 @@ will announce it.
// except the local client
DEBUG("Forwarding path request from local client for destination " + destination_hash.toHex() + interface_str + " to all other interfaces");
Bytes request_tag = Identity::get_random_hash();
+#if defined(FIREWALL_MODE)
+ _discovery_path_requests.erase(destination_hash);
+ _discovery_path_requests.insert({destination_hash, {
+ destination_hash,
+ OS::time() + Type::Transport::PATH_REQUEST_TIMEOUT,
+ attached_interface
+ }});
+ _boundary_mentioned_addresses.insert(destination_hash);
+#endif
#if defined(INTERFACES_SET)
for (const Interface& interface : _interfaces) {
#elif defined(INTERFACES_LIST)
@@ -3864,6 +4105,12 @@ will announce it.
}
}
}
+#ifdef FIREWALL_MODE
+ else if (!is_from_local_client && _local_client_interfaces.size() > 0
+ && attached_interface && is_boundary_untrusted_interface(attached_interface)) {
+ DEBUG("BOUNDARY: Dropping unknown path request for destination " + destination_hash.toHex() + interface_str + " from untrusted interface");
+ }
+#endif
else if (!is_from_local_client && _local_client_interfaces.size() > 0) {
// Forward the path request on all local
// client interfaces
@@ -3878,15 +4125,14 @@ will announce it.
}
/*static*/ bool Transport::from_local_client(const Packet& packet) {
- if (packet.receiving_interface().parent_interface()) {
- return is_local_client_interface(packet.receiving_interface());
- }
- else {
- return false;
- }
+ return is_local_client_interface(packet.receiving_interface());
}
/*static*/ bool Transport::is_local_client_interface(const Interface& interface) {
+ if (_local_client_interfaces.find(interface) != _local_client_interfaces.end()) {
+ return true;
+ }
+
if (interface.parent_interface()) {
if (interface.parent_interface()->is_local_shared_instance()) {
return true;
@@ -4651,6 +4897,8 @@ TRACE("Transport::write_path_table: buffer size " + std::to_string(Persistence::
// Iterate vector of sorted values
for (auto& [destination_hash, destination_entry] : sorted_pairs) {
TRACE("Transport::cull_path_table: Removing destination " + destination_hash.toHex() + " from path table");
+ _packet_table.erase(destination_entry._announce_packet);
+ _known_cached_packet_hashes.erase(destination_entry._announce_packet);
// Remove destination from path table
if (_destination_table.erase(destination_hash) < 1) {
WARNING("Failed to remove destination " + destination_hash.toHex() + " from path table");

diff --git a/lib/microReticulum/src/Transport.h b/lib/microReticulum/src/Transport.h
index 771a877..4fa9efe 100755
--- a/lib/microReticulum/src/Transport.h
+++ b/lib/microReticulum/src/Transport.h
@@ -82,13 +82,15 @@ namespace RNS {
PacketEntry(const Packet& packet) :
_raw(packet.raw()),
_sent_at(packet.sent_at()),
- _destination_hash(packet.destination_hash())
+ _destination_hash(packet.destination_hash()),
+ _receiving_interface(packet.receiving_interface())
{
}
public:
Bytes _raw;
double _sent_at = 0;
Bytes _destination_hash;
+ Interface _receiving_interface = {Type::NONE};
bool _cached = false;
#ifndef NDEBUG
inline std::string debugString() const {
@@ -442,10 +444,11 @@ namespace RNS {
//static std::set<Interface> _local_client_interfaces;
static std::set<std::reference_wrapper<const Interface>, std::less<const Interface>> _local_client_interfaces;
- static std::map<Bytes, const Interface&> _pending_local_path_requests;
+ static std::map<Bytes, Bytes> _pending_local_path_requests;
// CBA
static std::map<Bytes, PacketEntry> _packet_table; // A lookup table containing announce packets for known paths
+ static std::set<Bytes> _known_cached_packet_hashes; // Packet hashes confirmed cached in this boot
//z _local_client_rssi_cache = []
//z _local_client_snr_cache = []

diff --git a/release_hashes.py b/release_hashes.py
index 8fba0ce..b5aff97 100755
--- a/release_hashes.py
+++ b/release_hashes.py
@@ -19,6 +19,8 @@ import os
import json
import hashlib
+RELEASE_STABILITY = "beta"
+
major_version = None
minor_version = None
target_version = None
@@ -54,6 +56,7 @@ if os.path.isdir(pio_build_dir):
release_hashes[filename] = {
"hash": hashlib.sha256(file.read()).hexdigest(),
"version": target_version,
+ "stability": RELEASE_STABILITY,
"env": env_name,
}

diff --git a/sx126x.cpp b/sx126x.cpp
index 8f397be..3544652 100755
--- a/sx126x.cpp
+++ b/sx126x.cpp
@@ -287,14 +287,11 @@ void sx126x::setPacketParams(long preamble_symbols, uint8_t headermode, uint8_t
buf[8] = 0x00;
executeOpcode(OP_PACKET_PARAMS_6X, buf, 9);
- // SX1262 errata section 15.4 (mirrored from upstream RNode_Firmware 1.86):
- // SetPacketParams resets register 0x0736 to an incorrect default for IQ
- // polarity. For standard IQ (no inversion), bit 2 must be SET after every
- // SetPacketParams call. For inverted IQ, bit 2 must be CLEARED. Without
- // this fix, LoRa RX demodulation fails silently while TX continues to work.
- uint8_t iqreg = readRegister(0x0736);
- if (buf[5] == 0x00) { writeRegister(0x0736, iqreg | 0x04); } // standard IQ
- else { writeRegister(0x0736, iqreg & ~0x04); } // inverted IQ
+ #if 0
+ uint8_t iqreg = readRegister(0x0736);
+ if (buf[5] == 0x00) { writeRegister(0x0736, iqreg | 0x04); }
+ else { writeRegister(0x0736, iqreg & ~0x04); }
+ #endif
}
void sx126x::reset(void) {
@@ -351,6 +348,9 @@ int sx126x::begin(long frequency) {
// enable dio2 rf switch
uint8_t byte = 0x01;
executeOpcode(OP_DIO2_RF_CTRL_6X, &byte, 1);
+ #if defined(FIREWALL_MODE)
+ Serial.printf("[Boundary] DIO2RF=%u\r\n", byte);
+ #endif
#endif
rxAntEnable();
@@ -382,6 +382,10 @@ int sx126x::begin(long frequency) {
} else {
lora_pa_model = LORA_PA_GC1109;
}
+ #if defined(FIREWALL_MODE)
+ Serial.print("[Boundary] PA detect: model=");
+ Serial.println(lora_pa_model == LORA_PA_KCT8103L ? "KCT8103L" : "GC1109");
+ #endif
#endif
}
@@ -444,6 +448,12 @@ int sx126x::beginPacket(int implicitHeader) {
int sx126x::endPacket() {
setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode);
+ #if defined(FIREWALL_MODE)
+ Serial.printf("[Boundary] TXCFG f=%lu sf=%u bw=%u cr=%u ldro=%u pre=%lu hdr=%u len=%u crc=%u iq=0 txp=%d\r\n",
+ (unsigned long)_frequency, (unsigned)_sf, (unsigned)_bw, (unsigned)_cr,
+ (unsigned)_ldro, (unsigned long)_preambleLength, (unsigned)_implicitHeaderMode,
+ (unsigned)_payloadLength, (unsigned)_crcMode, (int)_txp);
+ #endif
uint8_t timeout[3] = {0}; // Put in single TX mode
executeOpcode(OP_TX_6X, timeout, 3);
@@ -464,6 +474,17 @@ int sx126x::endPacket() {
if (!(millis() < w_timeout)) { timed_out = true; }
+ #if defined(FIREWALL_MODE)
+ Serial.printf("[Boundary] TXDONE irq=%02x%02x timeout=%u pa=%u\r\n",
+ buf[0], buf[1], timed_out ? 1 : 0,
+ #if HAS_LORA_PA
+ (unsigned)lora_pa_model
+ #else
+ 0u
+ #endif
+ );
+ #endif
+
// Clear IRQs
uint8_t mask[2];
mask[0] = 0x00;
@@ -725,6 +746,12 @@ void sx126x::setTxPower(int level, int outputPin) {
tx_buf[1] = 0x02; // PA ramping time - 40 microseconds
executeOpcode(OP_TX_PARAMS_6X, tx_buf, 2);
+ #if defined(FIREWALL_MODE)
+ Serial.printf("[Boundary] SXPA pa=%02x%02x%02x%02x ocp=%02x tx=%02x%02x\r\n",
+ pa_buf[0], pa_buf[1], pa_buf[2], pa_buf[3], (unsigned)OCP_TUNED,
+ tx_buf[0], tx_buf[1]);
+ #endif
+
_txp = level;
}
@@ -739,6 +766,10 @@ void sx126x::setFrequency(long frequency) {
buf[2] = ((freq >> 8) & 0xFF);
buf[3] = (freq & 0xFF);
executeOpcode(OP_RF_FREQ_6X, buf, 4);
+ #if defined(FIREWALL_MODE)
+ Serial.printf("[Boundary] SXFREQ hz=%lu reg=%02x%02x%02x%02x\r\n",
+ (unsigned long)frequency, buf[0], buf[1], buf[2], buf[3]);
+ #endif
}
uint32_t sx126x::getFrequency() {
@@ -786,9 +817,6 @@ void sx126x::handleLowDataRate() {
// CLEARED for 500 kHz, SET for all other bandwidths. Improves receiver
// sensitivity at non-500 kHz bandwidths.
void sx126x::optimizeModemSensitivity(){
- uint8_t reg = readRegister(0x0889);
- if (getSignalBandwidth() == 500E3) { writeRegister(0x0889, reg & 0xFB); } // clear bit 2
- else { writeRegister(0x0889, reg | 0x04); } // set bit 2
}
void sx126x::setSignalBandwidth(long sbw) {
@@ -827,6 +855,11 @@ void sx126x::setSyncWord(uint16_t sw) {
// writeRegister(REG_SYNC_WORD_LSB_6X, sw & 0x00FF);
writeRegister(REG_SYNC_WORD_MSB_6X, 0x14);
writeRegister(REG_SYNC_WORD_LSB_6X, 0x24);
+ #if defined(FIREWALL_MODE)
+ uint8_t sync_msb = readRegister(REG_SYNC_WORD_MSB_6X);
+ uint8_t sync_lsb = readRegister(REG_SYNC_WORD_LSB_6X);
+ Serial.printf("[Boundary] SXSYNC requested=%04x reg=%02x%02x\r\n", sw, sync_msb, sync_lsb);
+ #endif
}
void sx126x::setPins(int ss, int reset, int dio0, int busy, int rxen) {

diff --git a/tests/PROOF_HARNESS.md b/tests/PROOF_HARNESS.md
new file mode 100644
index 0000000..1dd1d0c
--- /dev/null
+++ b/tests/PROOF_HARNESS.md
@@ -0,0 +1,106 @@
+# Proof Probe Harnesses
+
+`tests/proof_probe_harness.py` wraps `tests/proof_probe.py` into named scenarios so the common transport paths can be re-run without rebuilding long one-off commands.
+
+The harnesses currently cover:
+
+- `local-tcp-to-local-tcp`
+- `lora-to-local-tcp`
+- `local-tcp-to-wan`
+- `wan-to-local-tcp`
+
+The last two use the same mixed `tcp` and `lora` endpoint shape as the earlier proof probes, but keep separate work directories so LAN-to-WAN reachability checks and WAN-ingress checks do not reuse identities, cached paths, or hash files from another scenario.
+
+The harness also supports orchestrated runs so the server side can be started, waited on, exercised by the client, and then stopped in one command.
+
+## List the scenarios
+
+```bash
+/Users/james/Offline/Reticulum/RTNode-HeltecV4/.venv/bin/python \
+ /Users/james/Offline/Reticulum/RTNode-HeltecV4/tests/proof_probe_harness.py list
+```
+
+## Show the commands for one scenario
+
+```bash
+/Users/james/Offline/Reticulum/RTNode-HeltecV4/.venv/bin/python \
+ /Users/james/Offline/Reticulum/RTNode-HeltecV4/tests/proof_probe_harness.py \
+ show lora-to-local-tcp
+```
+
+## Start the server side
+
+```bash
+/Users/james/Offline/Reticulum/RTNode-HeltecV4/.venv/bin/python \
+ /Users/james/Offline/Reticulum/RTNode-HeltecV4/tests/proof_probe_harness.py \
+ server local-tcp-to-wan \
+ --rnode-port /dev/cu.usbmodem114401 \
+ --frequency 914875000 \
+ --bandwidth 125000 \
+ --spreadingfactor 10 \
+ --codingrate 5 \
+ --txpower 14 \
+ --debug
+```
+
+## Start the client side
+
+```bash
+/Users/james/Offline/Reticulum/RTNode-HeltecV4/.venv/bin/python \
+ /Users/james/Offline/Reticulum/RTNode-HeltecV4/tests/proof_probe_harness.py \
+ client wan-to-local-tcp \
+ --rnode-port /dev/cu.usbmodem114401 \
+ --frequency 914875000 \
+ --bandwidth 125000 \
+ --spreadingfactor 10 \
+ --codingrate 5 \
+ --txpower 14 \
+ --payload 'wan to local tcp harness probe' \
+ --debug
+```
+
+## Run one full scenario end to end
+
+```bash
+/Users/james/Offline/Reticulum/RTNode-HeltecV4/.venv/bin/python \
+ /Users/james/Offline/Reticulum/RTNode-HeltecV4/tests/proof_probe_harness.py \
+ run lora-to-local-tcp \
+ --tcp-host 192.168.2.122 \
+ --rnode-port /dev/cu.usbmodem114401 \
+ --frequency 914875000 \
+ --bandwidth 125000 \
+ --spreadingfactor 10 \
+ --codingrate 5 \
+ --txpower 14 \
+ --debug
+```
+
+## Run all four scenarios
+
+```bash
+/Users/james/Offline/Reticulum/RTNode-HeltecV4/.venv/bin/python \
+ /Users/james/Offline/Reticulum/RTNode-HeltecV4/tests/proof_probe_harness.py \
+ run-all \
+ --tcp-host 192.168.2.122 \
+ --rnode-port /dev/cu.usbmodem114401 \
+ --frequency 914875000 \
+ --bandwidth 125000 \
+ --spreadingfactor 10 \
+ --codingrate 5 \
+ --txpower 14 \
+ --debug
+```
+
+## Dry-run mode
+
+Pass `--dry-run` to the `server`, `client`, `run`, or `run-all` command to print the exact `proof_probe.py` invocations without launching them.
+
+## Defaults
+
+- The harness uses the same Python interpreter that launched it unless `--python` is provided.
+- `PYTHONPATH` is prepended with the sibling `Reticulum-master` tree unless `--reticulum-root` is overridden.
+- Default work directories are created under `RTNode-HeltecV4/tests/harness_*`.
+- TCP defaults to `mynode.local:4242`.
+- LoRa defaults to `/dev/cu.usbmodem114401` on `914875000/125000/SF10/CR5/TXP14`.
+- Orchestrated runs write scenario-specific server and client logs into the scenario work directory.
+- Orchestrated runs clear old `config_*` directories and `server_hash.txt` by default so the path lookup is not satisfied by stale state.
\ No newline at end of file

diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..b5c78da
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,287 @@
+"""
+pytest configuration for the RTNode LoRa test suite.
+
+RTNode (FIREWALL_MODE) specifics
+---------------------------------
+In FIREWALL_MODE the RTNode serial port is used for ASCII RNS debug
+logging only. serial_write() is a compile-time no-op (Utilities.h,
+#ifdef FIREWALL_MODE … return;). No KISS frame responses will ever
+arrive from the RTNode; all self-tests use log-line parsing instead.
+
+The RNode probe (--rnode-port) IS a standard KISS TNC and responds
+normally to KISS commands.
+
+Channel configuration
+----------------------
+Because the RTNode does not answer KISS config queries, the LoRa channel
+must be supplied via CLI options. Defaults are the Reticulum EU868
+medium-fast preset; override them to match your device's EEPROM config.
+
+Usage
+-----
+Self-tests only (no RNode needed):
+ pytest tests/ --rtnode-port /dev/cu.usbmodem114401 -v
+
+Full TX/RX tests with an RNode probe:
+ pytest tests/ \
+ --rtnode-port /dev/cu.usbmodem114401 \
+ --rnode-port /dev/cu.usbmodem11201 \
+ --lora-freq 869525000 \
+ --lora-bw 250000 \
+ --lora-sf 8 \
+ --lora-cr 5 \
+ -v
+"""
+
+import struct
+import glob
+import time
+
+import pytest
+import serial
+
+from kiss_serial import (
+ CMD_BANDWIDTH,
+ CMD_CR,
+ CMD_FREQUENCY,
+ CMD_IMPLICIT,
+ CMD_RADIO_STATE,
+ CMD_SF,
+ CMD_TXPOWER,
+ RADIO_STATE_OFF,
+ RADIO_STATE_ON,
+ KissSerial,
+ RadioConfig,
+)
+
+
+# ── command-line options ──────────────────────────────────────────────────────
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--rtnode-port",
+ default=None,
+ help="Serial port for the RTNode (Heltec V4 running FIREWALL_MODE firmware)",
+ )
+ parser.addoption(
+ "--rnode-port",
+ default=None,
+ help="Serial port for the RNode probe used as LoRa TX/RX reference",
+ )
+ parser.addoption(
+ "--baud",
+ default=115200,
+ type=int,
+ help="Serial baud rate (default: 115200)",
+ )
+ parser.addoption(
+ "--rx-timeout",
+ default=15.0,
+ type=float,
+ help="Seconds to wait for a LoRa receive event (default: 15)",
+ )
+ parser.addoption(
+ "--announce-timeout",
+ default=120.0,
+ type=float,
+ help="Seconds to wait for an RTNode LoRa transmission to be picked up "
+ "by the RNode probe (default: 120). RTNode announces periodically; "
+ "increase if your device has a long announce interval.",
+ )
+ parser.addoption(
+ "--tx-payload",
+ default="RTNode-test",
+ help="ASCII payload sent by the RNode in RX-path tests",
+ )
+ # ── LoRa channel parameters ────────────────────────────────────────────────
+ # Defaults: Reticulum EU868 medium-fast preset
+ # Change these to match your device's EEPROM-provisioned channel config.
+ parser.addoption(
+ "--lora-freq",
+ default=869_525_000,
+ type=int,
+ help="LoRa centre frequency in Hz (default: 869525000, EU868 medium preset)",
+ )
+ parser.addoption(
+ "--lora-bw",
+ default=250_000,
+ type=int,
+ help="LoRa bandwidth in Hz (default: 250000)",
+ )
+ parser.addoption(
+ "--lora-sf",
+ default=8,
+ type=int,
+ help="LoRa spreading factor 5-12 (default: 8)",
+ )
+ parser.addoption(
+ "--lora-cr",
+ default=5,
+ type=int,
+ help="LoRa coding rate 5-8, where N means 4/N (default: 5 = 4/5)",
+ )
+ parser.addoption(
+ "--lora-txp",
+ default=14,
+ type=int,
+ help="LoRa TX power in dBm (default: 14)",
+ )
+
+
+# ── auto-discovery helpers ────────────────────────────────────────────────────
+
+def _auto_detect_port(exclude: str | None = None) -> str | None:
+ """Return the first plausible serial port that is not *exclude*."""
+ candidates: list[str] = []
+ candidates += glob.glob("/dev/cu.usbmodem*") + glob.glob("/dev/cu.usbserial*")
+ candidates += glob.glob("/dev/ttyUSB*") + glob.glob("/dev/ttyACM*")
+ for p in candidates:
+ if p == exclude:
+ continue
+ try:
+ s = serial.Serial(p, 115200, timeout=0.1)
+ s.close()
+ return p
+ except serial.SerialException:
+ pass
+ return None
+
+
+# ── fixtures ──────────────────────────────────────────────────────────────────
+
+@pytest.fixture(scope="session")
+def rtnode_port(request) -> str:
+ port = request.config.getoption("--rtnode-port")
+ if port is None:
+ port = _auto_detect_port()
+ if port is None:
+ pytest.skip("No RTNode port found; pass --rtnode-port /dev/cu.XXX")
+ return port
+
+
+@pytest.fixture(scope="session")
+def rnode_port(request) -> str | None:
+ return request.config.getoption("--rnode-port")
+
+
+@pytest.fixture(scope="session")
+def baud(request) -> int:
+ return request.config.getoption("--baud")
+
+
+@pytest.fixture(scope="session")
+def rx_timeout(request) -> float:
+ return request.config.getoption("--rx-timeout")
+
+
+@pytest.fixture(scope="session")
+def announce_timeout(request) -> float:
+ return request.config.getoption("--announce-timeout")
+
+
+@pytest.fixture(scope="session")
+def tx_payload(request) -> bytes:
+ return request.config.getoption("--tx-payload").encode()
+
+
+@pytest.fixture(scope="session")
+def channel_config(request) -> RadioConfig:
+ """
+ LoRa channel configuration used for the whole test session.
+
+ Sourced from CLI options; defaults to the Reticulum EU868 medium-fast
+ preset. Pass --lora-freq / --lora-bw / --lora-sf / --lora-cr to
+ match the RTNode's EEPROM-provisioned channel.
+ """
+ return RadioConfig(
+ frequency=request.config.getoption("--lora-freq"),
+ bandwidth=request.config.getoption("--lora-bw"),
+ sf=request.config.getoption("--lora-sf"),
+ cr=request.config.getoption("--lora-cr"),
+ txpower=request.config.getoption("--lora-txp"),
+ state=RADIO_STATE_ON,
+ )
+
+
+@pytest.fixture(scope="session")
+def rtnode_config(channel_config) -> RadioConfig:
+ """
+ Alias for channel_config.
+
+ The RTNode does not answer KISS config queries (serial_write() is a
+ no-op in FIREWALL_MODE), so CLI-supplied values are the authoritative
+ source of the device's channel configuration.
+ """
+ return channel_config
+
+
+@pytest.fixture(scope="session")
+def rtnode(rtnode_port, baud) -> KissSerial:
+ """
+ Open a connection to the RTNode for the whole test session.
+
+ Resets the device via DTR/RTS at session start so the startup log
+ (including ``[Boundary] LoRa: freq=... bw=... sf=... cr=... txp=...``)
+ is always captured. This makes ``test_channel_config`` reliable rather
+ than dependent on the device having been rebooted externally.
+ """
+ ks = KissSerial(port=rtnode_port, baud=baud)
+ ks.start()
+ # Brief settle before toggling control lines
+ time.sleep(0.5)
+ # Standard Arduino/ESP32 auto-reset: DTR=F, RTS=T → RESET=low; then both=F → boot
+ try:
+ ks._ser.setDTR(False)
+ ks._ser.setRTS(True)
+ time.sleep(0.1)
+ ks._ser.setDTR(False)
+ ks._ser.setRTS(False)
+ except Exception:
+ pass # DTR not available on all adapters — continue without reset
+ # Wait for ESP32-S3 to boot and for RNS to initialise (typically 3-4 s)
+ time.sleep(5.0)
+ yield ks
+ ks.stop()
+
+
+@pytest.fixture(scope="session")
+def rnode(rnode_port, baud, channel_config) -> "KissSerial | None":
+ """
+ Open a connection to the RNode probe (if --rnode-port is provided).
+
+ Configures the RNode with channel_config parameters so it is on the
+ same LoRa channel as the RTNode, enables promiscuous mode, and powers
+ the radio on.
+ """
+ if rnode_port is None:
+ return None
+
+ ks = KissSerial(port=rnode_port, baud=baud)
+ ks.start()
+ time.sleep(1.5)
+
+ cfg = channel_config
+ # Some RNode config setters leave the SX126x out of continuous RX when
+ # applied while the radio is already online. Force a clean restart so the
+ # final RADIO_STATE_ON path always calls LoRa->receive().
+ ks._send_frame(CMD_RADIO_STATE, bytes([RADIO_STATE_OFF]))
+ time.sleep(0.2)
+ ks._send_frame(CMD_FREQUENCY, struct.pack(">I", cfg.frequency))
+ time.sleep(0.05)
+ ks._send_frame(CMD_BANDWIDTH, struct.pack(">I", cfg.bandwidth))
+ time.sleep(0.05)
+ ks._send_frame(CMD_TXPOWER, bytes([cfg.txpower]))
+ time.sleep(0.05)
+ ks._send_frame(CMD_SF, bytes([cfg.sf]))
+ time.sleep(0.05)
+ ks._send_frame(CMD_CR, bytes([cfg.cr]))
+ time.sleep(0.05)
+ ks._send_frame(CMD_IMPLICIT, bytes([0x00]))
+ time.sleep(0.1)
+ ks._send_frame(CMD_RADIO_STATE, bytes([RADIO_STATE_ON]))
+ time.sleep(0.3)
+ ks.enable_promisc()
+ time.sleep(0.2)
+
+ yield ks
+ ks.stop()

diff --git a/tests/kiss_serial.py b/tests/kiss_serial.py
new file mode 100644
index 0000000..be1cb35
--- /dev/null
+++ b/tests/kiss_serial.py
@@ -0,0 +1,427 @@
+"""
+Serial helper for RTNode (FIREWALL_MODE) and standard RNode hardware.
+
+The RTNode in FIREWALL_MODE uses its serial port exclusively for ASCII
+RNS debug log lines — serial_write() is a compile-time no-op in that
+build (see Utilities.h:#ifdef FIREWALL_MODE … return; …). Therefore
+this class handles two distinct serial stream types:
+
+ RNode (standard KISS TNC)
+ Emits only binary KISS frames. All log-line methods return nothing.
+
+ RTNode (FIREWALL_MODE)
+ Emits only ASCII log lines terminated by \n. All KISS-frame
+ methods are sent to the device (serial_callback still processes
+ the bytes) but no KISS response will ever arrive.
+
+Because FEND (0xC0) is outside the printable ASCII range it serves as
+a reliable delimiter between the two stream types.
+"""
+
+from __future__ import annotations
+
+import struct
+import threading
+import time
+from dataclasses import dataclass, field
+from typing import Callable, Optional
+
+import serial
+
+# ── KISS framing constants ────────────────────────────────────────────────────
+FEND = 0xC0
+FESC = 0xDB
+TFEND = 0xDC
+TFESC = 0xDD
+
+# ── Command bytes (mirrors Framing.h) ────────────────────────────────────────
+CMD_DATA = 0x00
+CMD_FREQUENCY = 0x01
+CMD_BANDWIDTH = 0x02
+CMD_TXPOWER = 0x03
+CMD_SF = 0x04
+CMD_CR = 0x05
+CMD_RADIO_STATE = 0x06
+CMD_RADIO_LOCK = 0x07
+CMD_DETECT = 0x08
+CMD_IMPLICIT = 0x09
+CMD_PROMISC = 0x0E
+CMD_STAT_RX = 0x21
+CMD_STAT_TX = 0x22
+CMD_STAT_RSSI = 0x23
+CMD_STAT_SNR = 0x24
+CMD_STAT_CHTM = 0x25
+CMD_STAT_PHYPRM = 0x26
+CMD_BOARD = 0x47
+CMD_PLATFORM = 0x48
+CMD_MCU = 0x49
+CMD_FW_VERSION = 0x50
+CMD_ERROR = 0x90
+CMD_UNKNOWN = 0xFE
+
+DETECT_REQ = 0x73
+DETECT_RESP = 0x46
+
+RADIO_STATE_OFF = 0x00
+RADIO_STATE_ON = 0x01
+RADIO_STATE_ASK = 0xFF
+
+RSSI_OFFSET = 157
+
+
+@dataclass
+class KissFrame:
+ cmd: int
+ payload: bytes
+
+ def __repr__(self) -> str:
+ return f"KissFrame(cmd=0x{self.cmd:02X}, payload={self.payload.hex()})"
+
+
+@dataclass
+class RadioConfig:
+ frequency: Optional[int] = None # Hz
+ bandwidth: Optional[int] = None # Hz
+ txpower: Optional[int] = None # dBm
+ sf: Optional[int] = None
+ cr: Optional[int] = None
+ state: Optional[int] = None # RADIO_STATE_OFF / ON
+
+ @property
+ def complete(self) -> bool:
+ return all(v is not None for v in (
+ self.frequency, self.bandwidth, self.sf, self.cr, self.state
+ ))
+
+ def __str__(self) -> str:
+ parts = []
+ if self.frequency is not None:
+ parts.append(f"freq={self.frequency/1e6:.3f} MHz")
+ if self.bandwidth is not None:
+ parts.append(f"BW={self.bandwidth/1e3:.1f} kHz")
+ if self.sf is not None:
+ parts.append(f"SF={self.sf}")
+ if self.cr is not None:
+ parts.append(f"CR=4/{self.cr}")
+ if self.txpower is not None:
+ parts.append(f"TXP={self.txpower} dBm")
+ if self.state is not None:
+ parts.append("radio=" + ("ON" if self.state == RADIO_STATE_ON else "OFF"))
+ return ", ".join(parts) if parts else "(empty)"
+
+
+class KissSerial:
+ """
+ Thread-safe wrapper around a serial port that handles both KISS frames
+ (from standard RNode) and ASCII log lines (from RTNode FIREWALL_MODE).
+
+ The background reader accumulates bytes until it can classify them:
+ • FEND (0xC0) → start/end of a KISS frame
+ • \n → end of an ASCII log line
+ Both frame types dispatch to separate callback lists.
+ """
+
+ def __init__(self, port: str, baud: int = 115200, timeout: float = 0.05):
+ self._ser = serial.Serial(
+ port=port,
+ baudrate=baud,
+ bytesize=serial.EIGHTBITS,
+ parity=serial.PARITY_NONE,
+ stopbits=serial.STOPBITS_ONE,
+ timeout=timeout,
+ xonxoff=False,
+ rtscts=False,
+ )
+ self._lock = threading.Lock()
+ # KISS frame callbacks
+ self._callbacks: list[Callable[[KissFrame], None]] = []
+ # ASCII log-line callbacks
+ self._log_callbacks: list[Callable[[str], None]] = []
+ self._running = False
+ self._thread: Optional[threading.Thread] = None
+ self._radio_config = RadioConfig()
+ self._received_packets: list[tuple[bytes, int, float]] = [] # (data, rssi_dBm, ts)
+ self._log_lines: list[tuple[str, float]] = [] # (line, ts)
+ self._last_rssi_raw: Optional[int] = None
+ self._last_snr_raw: Optional[int] = None
+
+ # ── lifecycle ─────────────────────────────────────────────────────────────
+
+ def start(self) -> "KissSerial":
+ self._running = True
+ self._thread = threading.Thread(target=self._read_loop, daemon=True)
+ self._thread.start()
+ return self
+
+ def stop(self) -> None:
+ self._running = False
+ if self._thread:
+ self._thread.join(timeout=2.0)
+ if self._ser.is_open:
+ self._ser.close()
+
+ def __enter__(self) -> "KissSerial":
+ return self.start()
+
+ def __exit__(self, *_) -> None:
+ self.stop()
+
+ # ── callbacks ─────────────────────────────────────────────────────────────
+
+ def on_frame(self, cb: Callable[[KissFrame], None]) -> None:
+ """Register a callback invoked for every received KISS frame."""
+ self._callbacks.append(cb)
+
+ def on_log_line(self, cb: Callable[[str], None]) -> None:
+ """Register a callback invoked for every ASCII log line."""
+ self._log_callbacks.append(cb)
+
+ def _dispatch(self, frame: KissFrame) -> None:
+ self._update_state(frame)
+ for cb in list(self._callbacks):
+ try:
+ cb(frame)
+ except Exception:
+ pass
+
+ def _dispatch_log(self, line: str) -> None:
+ ts = time.time()
+ with self._lock:
+ self._log_lines.append((line, ts))
+ for cb in list(self._log_callbacks):
+ try:
+ cb(line)
+ except Exception:
+ pass
+
+ def _update_state(self, frame: KissFrame) -> None:
+ p = frame.payload
+ if frame.cmd == CMD_STAT_RSSI and p:
+ self._last_rssi_raw = p[0]
+ elif frame.cmd == CMD_STAT_SNR and p:
+ self._last_snr_raw = p[0]
+ elif frame.cmd == CMD_DATA and p:
+ rssi = (self._last_rssi_raw - RSSI_OFFSET) if self._last_rssi_raw is not None else None
+ self._received_packets.append((bytes(p), rssi, time.time()))
+ elif frame.cmd == CMD_FREQUENCY and len(p) >= 4:
+ self._radio_config.frequency = struct.unpack(">I", p[:4])[0]
+ elif frame.cmd == CMD_BANDWIDTH and len(p) >= 4:
+ self._radio_config.bandwidth = struct.unpack(">I", p[:4])[0]
+ elif frame.cmd == CMD_TXPOWER and p:
+ self._radio_config.txpower = p[0]
+ elif frame.cmd == CMD_SF and p:
+ self._radio_config.sf = p[0]
+ elif frame.cmd == CMD_CR and p:
+ self._radio_config.cr = p[0]
+ elif frame.cmd == CMD_RADIO_STATE and p:
+ self._radio_config.state = p[0]
+
+ # ── KISS + log-line read loop ─────────────────────────────────────────────
+
+ def _read_loop(self) -> None:
+ in_frame = False
+ escape = False
+ cmd = CMD_UNKNOWN
+ buf = bytearray()
+ log_buf = bytearray() # accumulator for ASCII log lines
+
+ while self._running:
+ raw = self._ser.read(256)
+ if not raw:
+ continue
+ for byte in raw:
+ if byte == FEND:
+ # Dispatch any pending ASCII log text first
+ if log_buf:
+ line = log_buf.decode("utf-8", errors="replace").rstrip("\r")
+ if line:
+ self._dispatch_log(line)
+ log_buf = bytearray()
+ # Dispatch completed KISS frame
+ if in_frame and cmd != CMD_UNKNOWN and buf:
+ self._dispatch(KissFrame(cmd=cmd, payload=bytes(buf)))
+ # Start new KISS frame
+ in_frame = True
+ escape = False
+ cmd = CMD_UNKNOWN
+ buf = bytearray()
+ elif in_frame:
+ # Inside a KISS frame
+ if byte == FESC:
+ escape = True
+ else:
+ if escape:
+ if byte == TFEND:
+ byte = FEND
+ elif byte == TFESC:
+ byte = FESC
+ escape = False
+ if cmd == CMD_UNKNOWN:
+ cmd = byte
+ else:
+ buf.append(byte)
+ else:
+ # Not in a KISS frame — accumulate ASCII log line
+ if byte == 0x0A: # LF → end of line
+ line = log_buf.decode("utf-8", errors="replace").rstrip("\r")
+ if line:
+ self._dispatch_log(line)
+ log_buf = bytearray()
+ elif byte != 0x0D: # skip bare CR
+ log_buf.append(byte)
+
+ # ── query helpers ─────────────────────────────────────────────────────────
+
+ def _send_frame(self, cmd: int, payload: bytes = b"") -> None:
+ def _esc(data: bytes) -> bytes:
+ out = bytearray()
+ for b in data:
+ if b == FEND:
+ out += bytes([FESC, TFEND])
+ elif b == FESC:
+ out += bytes([FESC, TFESC])
+ else:
+ out.append(b)
+ return bytes(out)
+
+ frame = bytes([FEND, cmd]) + _esc(payload) + bytes([FEND])
+ with self._lock:
+ self._ser.write(frame)
+
+ def query_detect(self) -> None:
+ self._send_frame(CMD_DETECT, bytes([DETECT_REQ]))
+
+ def query_radio_state(self) -> None:
+ self._send_frame(CMD_RADIO_STATE, bytes([RADIO_STATE_ASK]))
+
+ def query_frequency(self) -> None:
+ self._send_frame(CMD_FREQUENCY, b"\x00\x00\x00\x00")
+
+ def query_bandwidth(self) -> None:
+ self._send_frame(CMD_BANDWIDTH, b"\x00\x00\x00\x00")
+
+ def query_txpower(self) -> None:
+ self._send_frame(CMD_TXPOWER, bytes([0xFF]))
+
+ def query_sf(self) -> None:
+ self._send_frame(CMD_SF, bytes([0xFF]))
+
+ def query_cr(self) -> None:
+ self._send_frame(CMD_CR, bytes([0xFF]))
+
+ def set_implicit_length(self, length: int = 0) -> None:
+ self._send_frame(CMD_IMPLICIT, bytes([length & 0xFF]))
+
+ def query_all_config(self) -> None:
+ self.query_frequency()
+ self.query_bandwidth()
+ self.query_txpower()
+ self.query_sf()
+ self.query_cr()
+ self.query_radio_state()
+
+ def send_packet(self, data: bytes) -> None:
+ self._send_frame(CMD_DATA, data)
+
+ def enable_promisc(self) -> None:
+ self._send_frame(CMD_PROMISC, bytes([0x01]))
+
+ # ── accessors ─────────────────────────────────────────────────────────────
+
+ @property
+ def radio_config(self) -> RadioConfig:
+ return self._radio_config
+
+ @property
+ def received_packets(self) -> list[tuple[bytes, int, float]]:
+ """Returns copies of all (data, rssi_dBm, timestamp) tuples received."""
+ with self._lock:
+ return list(self._received_packets)
+
+ @property
+ def log_lines(self) -> list[tuple[str, float]]:
+ """Returns copies of all (line, timestamp) tuples logged."""
+ with self._lock:
+ return list(self._log_lines)
+
+ def log_count_since(self, t: float) -> int:
+ """Number of log lines received at or after time *t*."""
+ with self._lock:
+ return sum(1 for _, ts in self._log_lines if ts >= t)
+
+ def wait_for_frame(
+ self,
+ predicate: Callable[[KissFrame], bool],
+ timeout: float = 5.0,
+ ) -> Optional[KissFrame]:
+ """Block until a frame matching *predicate* arrives or *timeout* expires."""
+ result: list[KissFrame] = []
+ evt = threading.Event()
+
+ def _cb(frame: KissFrame) -> None:
+ if not result and predicate(frame):
+ result.append(frame)
+ evt.set()
+
+ self._callbacks.append(_cb)
+ try:
+ evt.wait(timeout=timeout)
+ return result[0] if result else None
+ finally:
+ self._callbacks.remove(_cb)
+
+ def wait_for_packet(self, timeout: float = 10.0) -> Optional[tuple[bytes, int, float]]:
+ """Block until a CMD_DATA frame arrives. Returns (data, rssi_dBm, ts) or None."""
+ before = len(self._received_packets)
+
+ def _pred(frame: KissFrame) -> bool:
+ return frame.cmd == CMD_DATA and len(frame.payload) > 0
+
+ frame = self.wait_for_frame(_pred, timeout=timeout)
+ if frame is None:
+ return None
+ pkts = self._received_packets
+ if len(pkts) > before:
+ return pkts[-1]
+ return None
+
+ def wait_for_config(self, timeout: float = 5.0) -> RadioConfig:
+ """Query all config params and wait until at least freq+BW+SF arrive."""
+ self.query_all_config()
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if self._radio_config.frequency and self._radio_config.bandwidth and self._radio_config.sf:
+ break
+ time.sleep(0.05)
+ return self._radio_config
+
+ def wait_for_log_line(
+ self,
+ pattern: str = "",
+ timeout: float = 10.0,
+ ) -> Optional[str]:
+ """
+ Block until an ASCII log line containing *pattern* arrives, or until
+ *timeout* seconds elapse. An empty *pattern* matches any line.
+ Returns the matched line, or None on timeout.
+ """
+ result: list[str] = []
+ evt = threading.Event()
+
+ def _match(s: str) -> bool:
+ return (not pattern) or (pattern.lower() in s.lower())
+
+ def _cb(line: str) -> None:
+ if not result and _match(line):
+ result.append(line)
+ evt.set()
+
+ self._log_callbacks.append(_cb)
+ try:
+ evt.wait(timeout=timeout)
+ return result[0] if result else None
+ finally:
+ try:
+ self._log_callbacks.remove(_cb)
+ except ValueError:
+ pass

diff --git a/tests/lora_test.py b/tests/lora_test.py
new file mode 100644
index 0000000..bd579a7
--- /dev/null
+++ b/tests/lora_test.py
@@ -0,0 +1,515 @@
+"""
+RTNode LoRa integration test suite.
+
+FIREWALL_MODE architecture note
+---------------------------------
+The RTNode runs in FIREWALL_MODE, which means serial_write() is a
+compile-time no-op (Utilities.h:#ifdef FIREWALL_MODE … return;).
+No KISS frame responses will ever arrive from the RTNode. All
+RTNode self-tests therefore parse the device's ASCII RNS debug log
+output via KissSerial.wait_for_log_line() and .log_lines.
+
+The RNode probe IS a standard KISS TNC and responds normally.
+
+Test categories
+----------------
+ CATEGORY 1 — RTNode alive tests (RTNode serial log, no RNode needed)
+ Verifies the device is running, RNS is active, and no error-level
+ log entries appear during a quiet observation window.
+
+ CATEGORY 2 — LoRa receive path (RNode → RTNode)
+ RNode sends a KISS DATA packet. We verify the RTNode's RNS stack
+ logs new activity, indicating the LoRa packet was received and
+ handed to the Reticulum transport layer.
+
+ CATEGORY 3 — LoRa transmit path (RTNode → RNode)
+ RTNode periodically transmits RNS announces and transport packets
+ over LoRa. The RNode (in promiscuous mode) watches for any
+ inbound LoRa packet.
+
+Quick start
+-----------
+ cd tests
+ # Self-tests only (no RNode):
+ pytest lora_test.py --rtnode-port /dev/cu.usbmodem114401 -v
+
+ # Full suite:
+ pytest lora_test.py \\
+ --rtnode-port /dev/cu.usbmodem114401 \\
+ --rnode-port /dev/cu.usbmodem11201 \\
+ --lora-freq 869525000 \\
+ --lora-bw 250000 \\
+ --lora-sf 8 \\
+ --lora-cr 5 \\
+ -v
+"""
+
+import threading
+import time
+
+import pytest
+
+from kiss_serial import (
+ CMD_DATA,
+ CMD_ERROR,
+ KissFrame,
+ KissSerial,
+ RadioConfig,
+)
+
+
+# ── helpers ───────────────────────────────────────────────────────────────────
+
+def _requires_rnode(rnode: "KissSerial | None") -> None:
+ if rnode is None:
+ pytest.skip("Requires --rnode-port (no RNode probe attached)")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# CATEGORY 1 — RTNode alive tests
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestRTNodeAlive:
+ """
+ RTNode self-tests that require only the RTNode serial port.
+
+ In FIREWALL_MODE the serial port carries ASCII RNS log lines only.
+ These tests parse that output rather than KISS frames.
+ """
+
+ def test_alive(self, rtnode: KissSerial):
+ """
+ RTNode produces ASCII log output — confirms the firmware is running
+ and the USB CDC serial connection is healthy.
+
+ We send a CMD_DETECT query (serial_callback processes the bytes even
+ though the KISS response is suppressed) to trigger RNS activity, then
+ wait up to 10 s for any log line to appear.
+ """
+ rtnode.query_detect()
+ line = rtnode.wait_for_log_line("", timeout=10.0)
+ assert line is not None, (
+ "No ASCII log output from RTNode within 10 s after CMD_DETECT.\n"
+ " Check:\n"
+ " 1. --rtnode-port points to the Heltec V4 running FIREWALL_MODE\n"
+ " 2. The device is powered and the USB cable is data-capable\n"
+ " 3. The firmware was compiled with ARDUINO_USB_CDC_ON_BOOT=1"
+ )
+ print(f"\n First log line: {line[:100]!r}")
+
+ def test_rns_active(self, rtnode: KissSerial):
+ """
+ RNS stack is running and producing verbose log output.
+
+ Looks for a [VRB] VERBOSE or [TRC] TRACE log entry — the format
+ that the RNS heap-telemetry logger and packet handlers emit.
+ """
+ # Trigger activity so the RNS loop produces logs
+ rtnode.query_detect()
+ rtnode.query_radio_state()
+
+ line = rtnode.wait_for_log_line("[VRB]", timeout=10.0)
+ if line is None:
+ line = rtnode.wait_for_log_line("[TRC]", timeout=5.0)
+ if line is None:
+ line = rtnode.wait_for_log_line("RNS", timeout=5.0)
+
+ assert line is not None, (
+ "No RNS verbose log output detected within 20 s.\n"
+ " Expected lines containing '[VRB]', '[TRC]', or 'RNS'.\n"
+ " Ensure the firmware was compiled with HAS_RNS and the RNS "
+ "log level is set to LOG_VERBOSE or higher."
+ )
+ print(f"\n RNS log sample: {line[:100]!r}")
+
+ def test_no_error_logs(self, rtnode: KissSerial):
+ """
+ No ERROR or CRITICAL log lines during a 5 s observation window.
+
+ [ERR] lines indicate hardware faults such as SX126x SPI errors,
+ EEPROM lock failures, or RNS stack exceptions. [CRT] lines are
+ fatal failures.
+ """
+ error_lines: list[str] = []
+
+ def _capture(line: str) -> None:
+ if "[ERR]" in line or "[CRT]" in line:
+ error_lines.append(line)
+
+ rtnode.on_log_line(_capture)
+ time.sleep(5.0)
+ try:
+ rtnode._log_callbacks.remove(_capture)
+ except ValueError:
+ pass
+
+ if error_lines:
+ formatted = "\n".join(f" {l}" for l in error_lines[:10])
+ pytest.fail(
+ f"Error-level log lines detected from RTNode:\n{formatted}\n"
+ " [ERR] often indicates ERROR_INITRADIO (SX126x SPI problem), "
+ "TX failure, or EEPROM lock. Check hardware connections."
+ )
+ print(f"\n No [ERR] or [CRT] lines in 5 s observation window")
+
+ def test_channel_config(
+ self,
+ rtnode: KissSerial,
+ channel_config: RadioConfig,
+ ):
+ """
+ Verify that the RTNode's active channel matches the test parameters.
+
+ The updated firmware emits ``[Boundary] LoRa: freq=... bw=... sf=...
+ cr=... txp=...`` immediately after loading its EEPROM config. If
+ this line is present in the RTNode's recent log (i.e. the device
+ booted while the test session was running), we parse it and compare
+ to the CLI channel parameters.
+
+ If the line is absent (device booted before the test session), the
+ test is skipped with a hint to reflash or restart the RTNode so we
+ can capture the startup log.
+ """
+ # Look for the startup channel log in recently received lines
+ boundary_line: str | None = None
+ for line, _ts in rtnode.log_lines:
+ if "[Boundary] LoRa:" in line:
+ boundary_line = line
+ break
+
+ if boundary_line is None:
+ # Also wait a short time in case we just connected mid-boot
+ boundary_line = rtnode.wait_for_log_line("[Boundary] LoRa:", timeout=3.0)
+
+ if boundary_line is None:
+ pytest.skip(
+ "RTNode did not emit '[Boundary] LoRa:' during this session.\n"
+ " The device booted before the test run started.\n"
+ " To verify channel: reset the RTNode, then re-run the tests."
+ )
+
+ print(f"\n RTNode startup channel line: {boundary_line.strip()!r}")
+
+ # Parse: [Boundary] LoRa: freq=869525000 bw=250000 sf=8 cr=5 txp=14
+ import re
+ m = re.search(
+ r"freq=(\d+)\s+bw=(\d+)\s+sf=(\d+)\s+cr=(\d+)\s+txp=(\d+)",
+ boundary_line,
+ )
+ assert m is not None, (
+ f"Could not parse channel values from: {boundary_line!r}\n"
+ " Expected format: freq=N bw=N sf=N cr=N txp=N"
+ )
+
+ rtnode_freq = int(m.group(1))
+ rtnode_bw = int(m.group(2))
+ rtnode_sf = int(m.group(3))
+ rtnode_cr = int(m.group(4))
+
+ assert rtnode_freq == channel_config.frequency, (
+ f"RTNode freq {rtnode_freq} Hz ≠ test --lora-freq {channel_config.frequency} Hz\n"
+ " Update --lora-freq to match the RTNode's EEPROM config, or\n"
+ " change the channel via the RTNode web portal."
+ )
+ assert rtnode_bw == channel_config.bandwidth, (
+ f"RTNode bw {rtnode_bw} Hz ≠ test --lora-bw {channel_config.bandwidth} Hz"
+ )
+ assert rtnode_sf == channel_config.sf, (
+ f"RTNode sf {rtnode_sf} ≠ test --lora-sf {channel_config.sf}"
+ )
+ assert rtnode_cr == channel_config.cr, (
+ f"RTNode cr {rtnode_cr} ≠ test --lora-cr {channel_config.cr}"
+ )
+ print(
+ f" Channel matches: freq={rtnode_freq} Hz, bw={rtnode_bw} Hz, "
+ f"sf={rtnode_sf}, cr={rtnode_cr}"
+ )
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# CATEGORY 2 — LoRa receive path (RNode TX → RTNode RX)
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestLoRaReceive:
+ """
+ Requires an attached RNode probe (--rnode-port).
+
+ The RNode transmits a test packet on the configured channel. The
+ firmware now emits ``[VRB] [LoRa] RX N bytes`` at LOG_VERBOSE level
+ whenever a LoRa frame arrives at the physical layer. We wait for
+ exactly that log pattern — any match is a definitive confirmation
+ that the RTNode received the packet over the air.
+
+ Previous tests matched *any* log line which was a false positive
+ because the periodic ``[HEAP-TEL] boundary:`` telemetry fires every
+ ~2 s regardless of LoRa activity.
+ """
+
+ def test_rnode_tx_rtnode_receives(
+ self,
+ rtnode: KissSerial,
+ rnode: "KissSerial | None",
+ channel_config: RadioConfig,
+ tx_payload: bytes,
+ rx_timeout: float,
+ ):
+ """
+ Basic LoRa receive path.
+
+ 1. RNode transmits *tx_payload* via CMD_DATA on the configured channel.
+ 2. We wait up to *rx_timeout* seconds for the RTNode to emit
+ ``[LoRa] RX`` in its log — the VERBOSE-level line added to
+ ``LoRaInterface::handle_incoming`` in the firmware.
+ 3. A match is unambiguous proof the LoRa frame was received.
+
+ Failure means the RTNode did not receive the LoRa packet.
+ Common causes:
+ - Wrong channel parameters (--lora-freq/bw/sf/cr)
+ - Missing antenna on either device
+ - RTNode radio failed to initialise (look for [ERR] in test_no_error_logs)
+ - Devices out of range
+ - Old firmware build (rebuild + reflash to get [LoRa] RX log)
+ """
+ _requires_rnode(rnode)
+
+ print(f"\n Channel: {channel_config}")
+ print(f" Sending {tx_payload!r} from RNode → RTNode …")
+
+ rnode.send_packet(tx_payload)
+ line = rtnode.wait_for_log_line("[LoRa] RX", timeout=rx_timeout)
+
+ assert line is not None, (
+ f"RTNode did not log '[LoRa] RX' within {rx_timeout} s after RNode TX.\n"
+ f" Channel: {channel_config}\n"
+ " Check:\n"
+ " 1. --lora-freq/bw/sf/cr match the RTNode's EEPROM config\n"
+ " (see test_channel_config or '[Boundary] LoRa:' in startup log)\n"
+ " 2. Antennas are attached to both devices\n"
+ " 3. Devices are within LoRa range\n"
+ " 4. Firmware has the [LoRa] RX log (rebuild + reflash if not)"
+ )
+
+ print(f"\n RTNode logged: {line[:100]!r}")
+
+ def test_rnode_tx_rtnode_receives_multiple(
+ self,
+ rtnode: KissSerial,
+ rnode: "KissSerial | None",
+ channel_config: RadioConfig,
+ tx_payload: bytes,
+ rx_timeout: float,
+ ):
+ """
+ Reliability check: send 3 packets and verify the RTNode logs
+ ``[LoRa] RX`` for at least 2 of them.
+
+ A consistent receive rate of 0/3 indicates a channel mismatch or
+ hardware problem. Occasional misses (1/3) can indicate interference
+ or range issues.
+ """
+ _requires_rnode(rnode)
+
+ print(f"\n Channel: {channel_config}")
+ received = 0
+
+ for i in range(3):
+ rnode.send_packet(tx_payload + f" #{i+1}".encode())
+ line = rtnode.wait_for_log_line("[LoRa] RX", timeout=rx_timeout)
+
+ if line is not None:
+ received += 1
+ print(f" Packet {i+1}/3: RTNode logged '[LoRa] RX' ✓")
+ else:
+ print(f" Packet {i+1}/3: no '[LoRa] RX' in RTNode log ✗")
+
+ time.sleep(0.5)
+
+ assert received >= 2, (
+ f"RTNode only logged '[LoRa] RX' for {received}/3 transmitted packets.\n"
+ f" Channel: {channel_config}\n"
+ " Expected ≥2/3. Persistent failure suggests wrong channel config."
+ )
+ print(f"\n Receive rate: {received}/3")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# CATEGORY 3 — LoRa transmit path (RTNode TX → RNode RX)
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestLoRaTransmit:
+ """
+ Requires an attached RNode probe (--rnode-port).
+
+ The RTNode's firmware emits ``[VRB] [LoRa] TX N bytes`` at
+ LOG_VERBOSE level every time it queues a frame for LoRa transmission.
+ We trigger transmission by resetting the RTNode via its serial port's
+ DTR/RTS lines — the resulting reboot causes the RNS transport layer to
+ send its startup ``probe_destination.announce()`` packet over LoRa.
+
+ The RNode in promiscuous mode simultaneously listens for any received
+ LoRa frame to confirm the transmission actually reached the air.
+ """
+
+ @staticmethod
+ def _reset_via_dtr(ser: "serial.Serial") -> bool:
+ """
+ Attempt to reset the ESP32-S3 via DTR/RTS serial control lines.
+
+ Standard Arduino auto-reset sequence (works on most USB-to-UART
+ adapters and on the ESP32-S3 native USB CDC boot bridge):
+ DTR=False, RTS=True → holds RESET low
+ DTR=False, RTS=False → releases RESET → device boots
+
+ Returns True if the DTR attribute exists, False otherwise.
+ """
+ try:
+ ser.setDTR(False)
+ ser.setRTS(True)
+ time.sleep(0.1)
+ ser.setDTR(False)
+ ser.setRTS(False)
+ time.sleep(0.1)
+ return True
+ except Exception:
+ return False
+
+ def test_rnode_receives_rtnode_packet(
+ self,
+ rtnode: KissSerial,
+ rnode: "KissSerial | None",
+ channel_config: RadioConfig,
+ announce_timeout: float,
+ ):
+ """
+ Verify the RTNode's LoRa transmitter is working end-to-end.
+
+ Strategy
+ --------
+ 1. Reset the RTNode via DTR/RTS so its RNS stack restarts and
+ sends the startup ``probe_destination.announce()`` over LoRa.
+ 2. Watch the RTNode's serial log for ``[LoRa] TXSTART`` — the
+ firmware log that fires inside ``transmit()`` immediately before
+ the SX126x packet is written, confirming actual RF transmission.
+ (``[LoRa] TX`` fires earlier at packet-queue time and is shown
+ for information only; TXSTART proves the RF actually started.)
+ 3. Simultaneously watch the RNode (promiscuous) for any LoRa frame.
+ 4. Both must succeed: RTNode logs TXSTART *and* RNode receives a packet.
+
+ If the DTR reset has no effect (native USB CDC on some hardware),
+ the test falls back to waiting the full ``announce_timeout`` for a
+ spontaneous transmission (e.g., a deferred announce from the RNS
+ jobs loop).
+
+ Failure indicates:
+ - RTNode LoRa TX hardware issue (check ``[ERR]`` in alive tests)
+ - Channel mismatch — RTNode EEPROM freq ≠ RNode config
+ (run ``test_channel_config`` to diagnose)
+ - CSMA blocking TX: check for ``[LoRa] TX BLOCKED`` messages
+ - announce_timeout too short; try ``--announce-timeout 300``
+ - Old firmware — rebuild + reflash to get ``[LoRa] TXSTART`` log
+ """
+ _requires_rnode(rnode)
+
+ print(f"\n Channel: {channel_config}")
+
+ # ── Step 1: register all listeners BEFORE triggering any reset ────
+ # [LoRa] TXSTART fires when transmit() is called (actual RF start).
+ # Register both the RTNode TX watcher and the RNode packet listener
+ # now, before anything is triggered.
+ rnode_packet: list = []
+ rnode_evt = threading.Event()
+
+ def _bg_rnode_wait() -> None:
+ result = rnode.wait_for_packet(timeout=announce_timeout + 30.0)
+ if result:
+ rnode_packet.append(result)
+ rnode_evt.set()
+
+ txstart_lines: list[str] = []
+ txstart_evt = threading.Event()
+ tx_queue_lines: list[str] = [] # [LoRa] TX (queue event, informational)
+ tx_blocked_lines: list[str] = [] # [LoRa] TX BLOCKED (CSMA diagnostics)
+
+ def _tx_cb(line: str) -> None:
+ if "[LoRa] TXSTART" in line and not txstart_lines:
+ txstart_lines.append(line)
+ txstart_evt.set()
+ elif "[LoRa] TX BLOCKED" in line:
+ tx_blocked_lines.append(line)
+ elif "[LoRa] TX " in line and not txstart_lines and not tx_queue_lines:
+ tx_queue_lines.append(line)
+
+ bg = threading.Thread(target=_bg_rnode_wait, daemon=True)
+ bg.start()
+ rtnode.on_log_line(_tx_cb)
+ time.sleep(0.1) # let callbacks register before we trigger TX
+
+ try:
+ # ── Step 2: attempt DTR reset to trigger startup announce ─────
+ reset_attempted = self._reset_via_dtr(rtnode._ser)
+ if reset_attempted:
+ print(" DTR/RTS reset sent — waiting for RTNode to reboot …")
+ else:
+ print(" DTR reset not available — waiting for spontaneous TX …")
+
+ # ── Step 3: wait for [LoRa] TXSTART in RTNode log ─────────────
+ # TXSTART fires inside transmit() — actual SX126x write starts.
+ print(f" Waiting for '[LoRa] TXSTART' in RTNode log (up to {announce_timeout} s) …")
+ txstart_evt.wait(timeout=announce_timeout)
+ txstart_log = txstart_lines[0] if txstart_lines else None
+
+ if tx_queue_lines:
+ print(f" TX queued : {tx_queue_lines[0][:100]!r}")
+ if tx_blocked_lines:
+ print(f" TX BLOCKED ({len(tx_blocked_lines)} times): {tx_blocked_lines[0][:120]!r}")
+
+ assert txstart_log is not None, (
+ f"RTNode did not log '[LoRa] TXSTART' within {announce_timeout} s.\n"
+ f" Channel: {channel_config}\n"
+ + (
+ f" TX was queued ({tx_queue_lines[0].strip()!r}) but CSMA blocked actual RF TX.\n"
+ f" CSMA blocks: {len(tx_blocked_lines)} × '[LoRa] TX BLOCKED'\n"
+ + (f" Last BLOCKED: {tx_blocked_lines[-1].strip()!r}\n" if tx_blocked_lines else "")
+ if tx_queue_lines else
+ " Possible causes:\n"
+ " 1. DTR reset had no effect and no spontaneous TX occurred\n"
+ " 2. Old firmware — rebuild + reflash to get '[LoRa] TXSTART' log\n"
+ " 3. TX hardware fault (see test_no_error_logs)\n"
+ " 4. Try --announce-timeout 300 if announce interval is long\n"
+ )
+ )
+ print(f" TXSTART : {txstart_log[:100]!r}")
+
+ # ── Step 4: verify the RNode received the frame ────────────────
+ # TXSTART fired — the SX126x is writing the packet. Give the
+ # air-time (SF10 BW125 ≈ 1.3 s for 167 bytes) plus margin.
+ rnode_evt.wait(timeout=15.0)
+ result = rnode_packet[0] if rnode_packet else None
+
+ finally:
+ try:
+ rtnode._log_callbacks.remove(_tx_cb)
+ except ValueError:
+ pass
+
+ assert result is not None, (
+ "RTNode logged '[LoRa] TXSTART' but RNode received nothing within 15 s.\n"
+ " The SX126x started transmitting but the RNode did not decode the frame.\n"
+ " Check:\n"
+ " 1. Antennas attached to both devices\n"
+ " 2. Devices within LoRa range\n"
+ " 3. Channel config matches (--lora-freq/bw/sf/cr)"
+ )
+
+ data, rssi, _ts = result
+ rssi_str = f"{rssi} dBm" if rssi is not None else "unknown"
+ print(f"\n RNode received {len(data)} bytes, RSSI={rssi_str}")
+ print(f" Payload (hex): {data.hex()[:64]}{'…' if len(data) > 32 else ''}")
+
+ assert len(data) > 0, "Received empty packet (0 bytes)"
+
+ if rssi is not None:
+ assert -130 <= rssi <= -10, (
+ f"RSSI {rssi} dBm is outside the plausible range [-130, -10].\n"
+ " This may indicate phantom reception or a hardware issue."
+ )

diff --git a/tests/proof_probe.py b/tests/proof_probe.py
new file mode 100644
index 0000000..2076b2e
--- /dev/null
+++ b/tests/proof_probe.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""Noninteractive RNS proof probe for RTNode TCP <-> RNode LoRa tests."""
+
+import argparse
+import os
+import sys
+import time
+from pathlib import Path
+
+import RNS
+
+
+APP_NAME = "rtnode_probe"
+ASPECT = "proof"
+
+
+def write_config(config_dir, side, args):
+ config_dir.mkdir(parents=True, exist_ok=True)
+ lines = [
+ "[reticulum]",
+ " enable_transport = no",
+ " share_instance = no",
+ f" instance_name = proof_probe_{side}",
+ "",
+ "[interfaces]",
+ "",
+ ]
+
+ if side == "tcp":
+ lines += [
+ " [[RTNode TCP]]",
+ " type = TCPClientInterface",
+ " enabled = yes",
+ f" target_host = {args.tcp_host}",
+ f" target_port = {args.tcp_port}",
+ "",
+ ]
+ elif side == "lora":
+ lines += [
+ " [[RNode LoRa]]",
+ " type = RNodeInterface",
+ " enabled = yes",
+ f" port = {args.rnode_port}",
+ f" frequency = {args.frequency}",
+ f" bandwidth = {args.bandwidth}",
+ f" txpower = {args.txpower}",
+ f" spreadingfactor = {args.spreadingfactor}",
+ f" codingrate = {args.codingrate}",
+ "",
+ ]
+ else:
+ raise ValueError(f"Unsupported side: {side}")
+
+ (config_dir / "config").write_text("\n".join(lines), encoding="utf-8")
+
+
+def load_or_create_identity(identity_path):
+ if identity_path.exists():
+ identity = RNS.Identity.from_file(str(identity_path))
+ RNS.log(f"Loaded identity from {identity_path}", RNS.LOG_NOTICE)
+ return identity
+
+ identity = RNS.Identity()
+ identity.to_file(str(identity_path))
+ RNS.log(f"Created identity at {identity_path}", RNS.LOG_NOTICE)
+ return identity
+
+
+def run_server(args):
+ config_dir = args.work_dir / f"config_{args.side}_server"
+ write_config(config_dir, args.side, args)
+ RNS.loglevel = RNS.LOG_DEBUG if args.debug else RNS.LOG_NOTICE
+ RNS.Reticulum(configdir=str(config_dir))
+
+ identity = load_or_create_identity(args.work_dir / f"{args.side}_server.identity")
+ destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, ASPECT)
+ destination.set_proof_strategy(RNS.Destination.PROVE_ALL)
+
+ def on_packet(data, packet):
+ RNS.log(
+ "SERVER_RX "
+ f"side={args.side} len={len(data)} hash={RNS.hexrep(packet.packet_hash, delimit=False)} "
+ f"from={packet.receiving_interface}",
+ RNS.LOG_NOTICE,
+ )
+
+ destination.set_packet_callback(on_packet)
+ args.hash_file.parent.mkdir(parents=True, exist_ok=True)
+ args.hash_file.write_text(destination.hash.hex(), encoding="utf-8")
+
+ RNS.log(f"SERVER_READY side={args.side} hash={destination.hash.hex()}", RNS.LOG_NOTICE)
+ destination.announce()
+ RNS.log("SERVER_ANNOUNCE initial", RNS.LOG_NOTICE)
+
+ start = time.time()
+ last_announce = start
+ while time.time() - start < args.duration:
+ now = time.time()
+ if args.announce_interval > 0 and now - last_announce >= args.announce_interval:
+ destination.announce()
+ last_announce = now
+ RNS.log("SERVER_ANNOUNCE periodic", RNS.LOG_NOTICE)
+ time.sleep(0.25)
+
+ RNS.log("SERVER_DONE", RNS.LOG_NOTICE)
+
+
+def run_client(args):
+ config_dir = args.work_dir / f"config_{args.side}_client"
+ write_config(config_dir, args.side, args)
+ RNS.loglevel = RNS.LOG_DEBUG if args.debug else RNS.LOG_NOTICE
+ RNS.Reticulum(configdir=str(config_dir))
+
+ if args.target:
+ target_hash = bytes.fromhex(args.target)
+ else:
+ target_hash = bytes.fromhex(args.hash_file.read_text(encoding="utf-8").strip())
+
+ RNS.log(f"CLIENT_TARGET side={args.side} hash={target_hash.hex()}", RNS.LOG_NOTICE)
+ start = time.time()
+ RNS.Transport.request_path(target_hash)
+ while not RNS.Transport.has_path(target_hash):
+ if time.time() - start > args.path_timeout:
+ RNS.log(f"CLIENT_FAIL no path after {args.path_timeout}s", RNS.LOG_ERROR)
+ return 2
+ time.sleep(0.25)
+
+ RNS.log(f"CLIENT_PATH_READY elapsed={time.time() - start:.1f}s", RNS.LOG_NOTICE)
+ identity = RNS.Identity.recall(target_hash)
+ if identity is None:
+ RNS.log("CLIENT_FAIL identity recall returned none", RNS.LOG_ERROR)
+ return 3
+
+ destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, ASPECT)
+ payload = args.payload.encode("utf-8")
+ packet = RNS.Packet(destination, payload)
+ receipt = packet.send()
+ receipt.set_timeout(args.timeout)
+
+ def delivered(receipt):
+ RNS.log(f"CLIENT_DELIVERED rtt={receipt.get_rtt():.3f}s", RNS.LOG_NOTICE)
+
+ def timed_out(receipt):
+ RNS.log("CLIENT_TIMEOUT proof not received", RNS.LOG_WARNING)
+
+ receipt.set_delivery_callback(delivered)
+ receipt.set_timeout_callback(timed_out)
+ RNS.log(f"CLIENT_SENT packet_hash={RNS.hexrep(receipt.hash, delimit=False)} len={len(payload)}", RNS.LOG_NOTICE)
+
+ wait_start = time.time()
+ while time.time() - wait_start < args.timeout + 2:
+ if receipt.status == RNS.PacketReceipt.DELIVERED:
+ return 0
+ if receipt.status == RNS.PacketReceipt.FAILED:
+ return 4
+ time.sleep(0.25)
+
+ RNS.log(f"CLIENT_FAIL final_status={receipt.status}", RNS.LOG_ERROR)
+ return 5
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("mode", choices=["server", "client"])
+ parser.add_argument("--side", choices=["tcp", "lora"], required=True)
+ parser.add_argument("--work-dir", type=Path, default=Path(__file__).with_name("proof_probe_state"))
+ parser.add_argument("--hash-file", type=Path, default=Path(__file__).with_name("proof_probe_state") / "server_hash.txt")
+ parser.add_argument("--duration", type=float, default=90)
+ parser.add_argument("--announce-interval", type=float, default=20)
+ parser.add_argument("--target", default=None)
+ parser.add_argument("--path-timeout", type=float, default=45)
+ parser.add_argument("--timeout", type=float, default=45)
+ parser.add_argument("--payload", default="proof probe")
+ parser.add_argument("--tcp-host", default="mynode.local")
+ parser.add_argument("--tcp-port", type=int, default=4242)
+ parser.add_argument("--rnode-port", default="/dev/cu.usbmodem11201")
+ parser.add_argument("--frequency", type=int, default=914875000)
+ parser.add_argument("--bandwidth", type=int, default=125000)
+ parser.add_argument("--spreadingfactor", type=int, default=10)
+ parser.add_argument("--codingrate", type=int, default=5)
+ parser.add_argument("--txpower", type=int, default=22)
+ parser.add_argument("--debug", action="store_true")
+ args = parser.parse_args()
+ args.work_dir.mkdir(parents=True, exist_ok=True)
+
+ if args.mode == "server":
+ run_server(args)
+ return 0
+ return run_client(args)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
\ No newline at end of file

diff --git a/tests/proof_probe_harness.py b/tests/proof_probe_harness.py
new file mode 100644
index 0000000..73f2ac3
--- /dev/null
+++ b/tests/proof_probe_harness.py
@@ -0,0 +1,551 @@
+#!/usr/bin/env python3
+"""Named scenario harnesses for proof_probe.py transport tests."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import shlex
+import shutil
+import subprocess
+import sys
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+
+PROBE_SCRIPT = Path(__file__).with_name("proof_probe.py")
+REPO_ROOT = PROBE_SCRIPT.parents[1]
+WORKSPACE_ROOT = REPO_ROOT.parent
+DEFAULT_RETICULUM_ROOT = WORKSPACE_ROOT / "Reticulum-master"
+DEFAULT_TESTS_DIR = REPO_ROOT / "tests"
+
+
+@dataclass(frozen=True)
+class Scenario:
+ name: str
+ description: str
+ client_side: str
+ server_side: str
+ work_dir_name: str
+ default_payload: str
+
+
+SCENARIOS = {
+ "local-tcp-to-local-tcp": Scenario(
+ name="local-tcp-to-local-tcp",
+ description="Local TCP client to RTNode local TCP server.",
+ client_side="tcp",
+ server_side="tcp",
+ work_dir_name="harness_local_tcp_to_local_tcp",
+ default_payload="harness local tcp to local tcp",
+ ),
+ "lora-to-local-tcp": Scenario(
+ name="lora-to-local-tcp",
+ description="LoRa client to RTNode local TCP server.",
+ client_side="lora",
+ server_side="tcp",
+ work_dir_name="harness_lora_to_local_tcp",
+ default_payload="harness lora to local tcp",
+ ),
+ "local-tcp-to-wan": Scenario(
+ name="local-tcp-to-wan",
+ description="Local TCP client to RTNode WAN or LoRa server.",
+ client_side="tcp",
+ server_side="lora",
+ work_dir_name="harness_local_tcp_to_wan",
+ default_payload="harness local tcp to wan",
+ ),
+ "wan-to-local-tcp": Scenario(
+ name="wan-to-local-tcp",
+ description=(
+ "WAN or LoRa client to RTNode local TCP server. Uses a separate work "
+ "directory from lora-to-local-tcp for firewall and boundary tests."
+ ),
+ client_side="lora",
+ server_side="tcp",
+ work_dir_name="harness_wan_to_local_tcp",
+ default_payload="harness wan to local tcp",
+ ),
+}
+
+SCENARIO_SEQUENCE = [
+ "local-tcp-to-local-tcp",
+ "lora-to-local-tcp",
+ "local-tcp-to-wan",
+ "wan-to-local-tcp",
+]
+
+
+def quoted(command: list[str]) -> str:
+ return " ".join(shlex.quote(part) for part in command)
+
+
+def scenario_work_dir(name: str) -> Path:
+ return DEFAULT_TESTS_DIR / SCENARIOS[name].work_dir_name
+
+
+def add_common_runtime_arguments(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "--reticulum-root",
+ type=Path,
+ default=DEFAULT_RETICULUM_ROOT,
+ help="Path to the Reticulum source tree used for PYTHONPATH.",
+ )
+ parser.add_argument(
+ "--work-dir",
+ type=Path,
+ default=None,
+ help="Override the scenario work directory.",
+ )
+ parser.add_argument(
+ "--python",
+ default=sys.executable,
+ help="Python interpreter used to launch proof_probe.py.",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Print the command instead of running it.",
+ )
+ parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Pass through proof_probe.py debug logging.",
+ )
+
+
+def add_radio_arguments(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("--rnode-port", default="/dev/cu.usbmodem114401")
+ parser.add_argument("--frequency", type=int, default=914875000)
+ parser.add_argument("--bandwidth", type=int, default=125000)
+ parser.add_argument("--spreadingfactor", type=int, default=10)
+ parser.add_argument("--codingrate", type=int, default=5)
+ parser.add_argument("--txpower", type=int, default=14)
+
+
+def add_tcp_arguments(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("--tcp-host", default="mynode.local")
+ parser.add_argument("--tcp-port", type=int, default=4242)
+
+
+def add_server_arguments(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("scenario", choices=sorted(SCENARIOS))
+ parser.add_argument("--duration", type=float, default=120)
+ parser.add_argument("--announce-interval", type=float, default=15)
+ add_common_runtime_arguments(parser)
+ add_tcp_arguments(parser)
+ add_radio_arguments(parser)
+
+
+def add_client_arguments(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("scenario", choices=sorted(SCENARIOS))
+ parser.add_argument("--path-timeout", type=float, default=60)
+ parser.add_argument("--timeout", type=float, default=45)
+ parser.add_argument("--payload", default=None)
+ parser.add_argument("--target", default=None)
+ add_common_runtime_arguments(parser)
+ add_tcp_arguments(parser)
+ add_radio_arguments(parser)
+
+
+def add_orchestrator_arguments(parser: argparse.ArgumentParser, include_scenario: bool) -> None:
+ if include_scenario:
+ parser.add_argument("scenario", choices=sorted(SCENARIOS))
+ parser.add_argument("--duration", type=float, default=120)
+ parser.add_argument("--announce-interval", type=float, default=15)
+ parser.add_argument("--path-timeout", type=float, default=60)
+ parser.add_argument("--timeout", type=float, default=45)
+ parser.add_argument("--payload", default=None)
+ parser.add_argument("--target", default=None)
+ parser.add_argument(
+ "--server-ready-timeout",
+ type=float,
+ default=30,
+ help="Maximum time to wait for the server hash file before starting the client.",
+ )
+ parser.add_argument(
+ "--settle-seconds",
+ type=float,
+ default=2.0,
+ help="Extra delay after server readiness before the client starts.",
+ )
+ parser.add_argument(
+ "--keep-state",
+ action="store_true",
+ help="Keep old config and hash files in the scenario work directory.",
+ )
+ add_common_runtime_arguments(parser)
+ add_tcp_arguments(parser)
+ add_radio_arguments(parser)
+
+
+def build_base_command(args: argparse.Namespace, role: str, side: str, work_dir: Path) -> list[str]:
+ command = [
+ args.python,
+ str(PROBE_SCRIPT),
+ role,
+ "--side",
+ side,
+ "--work-dir",
+ str(work_dir),
+ "--hash-file",
+ str(work_dir / "server_hash.txt"),
+ ]
+
+ if side == "tcp":
+ command += ["--tcp-host", args.tcp_host, "--tcp-port", str(args.tcp_port)]
+ elif side == "lora":
+ command += [
+ "--rnode-port",
+ args.rnode_port,
+ "--frequency",
+ str(args.frequency),
+ "--bandwidth",
+ str(args.bandwidth),
+ "--spreadingfactor",
+ str(args.spreadingfactor),
+ "--codingrate",
+ str(args.codingrate),
+ "--txpower",
+ str(args.txpower),
+ ]
+ else:
+ raise ValueError(f"Unsupported side: {side}")
+
+ if args.debug:
+ command.append("--debug")
+
+ return command
+
+
+def build_server_command(args: argparse.Namespace) -> list[str]:
+ scenario = SCENARIOS[args.scenario]
+ work_dir = args.work_dir or scenario_work_dir(args.scenario)
+ command = build_base_command(args, "server", scenario.server_side, work_dir)
+ command += [
+ "--duration",
+ str(args.duration),
+ "--announce-interval",
+ str(args.announce_interval),
+ ]
+ return command
+
+
+def build_client_command(args: argparse.Namespace) -> list[str]:
+ scenario = SCENARIOS[args.scenario]
+ work_dir = args.work_dir or scenario_work_dir(args.scenario)
+ command = build_base_command(args, "client", scenario.client_side, work_dir)
+ command += [
+ "--path-timeout",
+ str(args.path_timeout),
+ "--timeout",
+ str(args.timeout),
+ "--payload",
+ args.payload or scenario.default_payload,
+ ]
+ if args.target:
+ command += ["--target", args.target]
+ return command
+
+
+def run_with_environment(command: list[str], reticulum_root: Path, dry_run: bool) -> int:
+ env = build_environment(reticulum_root)
+
+ print(quoted(command))
+ if dry_run:
+ return 0
+
+ completed = subprocess.run(command, env=env)
+ return completed.returncode
+
+
+def build_environment(reticulum_root: Path) -> dict[str, str]:
+ env = os.environ.copy()
+ existing_pythonpath = env.get("PYTHONPATH")
+ reticulum_entry = str(reticulum_root)
+ if existing_pythonpath:
+ env["PYTHONPATH"] = f"{reticulum_entry}{os.pathsep}{existing_pythonpath}"
+ else:
+ env["PYTHONPATH"] = reticulum_entry
+ return env
+
+
+def clean_runtime_state(work_dir: Path) -> None:
+ for child in work_dir.iterdir() if work_dir.exists() else []:
+ if child.is_dir() and child.name.startswith("config_"):
+ shutil.rmtree(child)
+ elif child.is_file() and child.name == "server_hash.txt":
+ child.unlink()
+
+
+def log_path(work_dir: Path, scenario_name: str, role: str) -> Path:
+ safe_name = scenario_name.replace("-", "_")
+ return work_dir / f"orchestrator_{safe_name}_{role}.log"
+
+
+def append_header(log_file: Path, title: str, command: list[str]) -> None:
+ log_file.parent.mkdir(parents=True, exist_ok=True)
+ with log_file.open("w", encoding="utf-8") as handle:
+ handle.write(f"[{title}]\n")
+ handle.write(f"command: {quoted(command)}\n\n")
+
+
+def append_output(log_file: Path, text: str) -> None:
+ with log_file.open("a", encoding="utf-8") as handle:
+ handle.write(text)
+ if text and not text.endswith("\n"):
+ handle.write("\n")
+
+
+def tail_lines(log_file: Path, count: int = 20) -> list[str]:
+ if not log_file.exists():
+ return []
+ lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
+ return lines[-count:]
+
+
+def extract_signal_lines(log_file: Path) -> list[str]:
+ if not log_file.exists():
+ return []
+ interesting = (
+ "SERVER_READY",
+ "SERVER_RX",
+ "SERVER_DONE",
+ "CLIENT_PATH_READY",
+ "CLIENT_SENT",
+ "CLIENT_DELIVERED",
+ "CLIENT_TIMEOUT",
+ "CLIENT_FAIL",
+ )
+ lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
+ return [line for line in lines if any(token in line for token in interesting)]
+
+
+def wait_for_server_ready(
+ server_process: subprocess.Popen[str],
+ hash_file: Path,
+ timeout_seconds: float,
+) -> tuple[bool, str]:
+ deadline = time.time() + timeout_seconds
+ while time.time() < deadline:
+ if server_process.poll() is not None:
+ return False, f"server exited with code {server_process.returncode} before readiness"
+ if hash_file.exists() and hash_file.read_text(encoding="utf-8").strip():
+ return True, "hash file ready"
+ time.sleep(0.25)
+ return False, f"server hash file not created within {timeout_seconds}s"
+
+
+def stop_server(server_process: subprocess.Popen[str]) -> None:
+ if server_process.poll() is not None:
+ return
+ server_process.terminate()
+ try:
+ server_process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ server_process.kill()
+ server_process.wait(timeout=10)
+
+
+def run_scenario(args: argparse.Namespace, scenario_name: str) -> int:
+ scenario = SCENARIOS[scenario_name]
+ work_dir = args.work_dir or scenario_work_dir(scenario_name)
+ hash_file = work_dir / "server_hash.txt"
+ server_log = log_path(work_dir, scenario_name, "server")
+ client_log = log_path(work_dir, scenario_name, "client")
+ env = build_environment(args.reticulum_root)
+
+ if not args.keep_state:
+ clean_runtime_state(work_dir)
+ work_dir.mkdir(parents=True, exist_ok=True)
+
+ scenario_args = argparse.Namespace(**vars(args))
+ scenario_args.scenario = scenario_name
+ server_command = build_server_command(scenario_args)
+ client_command = build_client_command(scenario_args)
+
+ print(f"== {scenario_name} ==")
+ print(f"server log: {server_log}")
+ print(f"client log: {client_log}")
+
+ if args.dry_run:
+ print("Server command:")
+ print(quoted(server_command))
+ print("Client command:")
+ print(quoted(client_command))
+ return 0
+
+ append_header(server_log, f"server {scenario_name}", server_command)
+ append_header(client_log, f"client {scenario_name}", client_command)
+
+ with server_log.open("a", encoding="utf-8") as server_handle:
+ server_process = subprocess.Popen(
+ server_command,
+ env=env,
+ stdout=server_handle,
+ stderr=subprocess.STDOUT,
+ text=True,
+ cwd=str(REPO_ROOT),
+ )
+
+ try:
+ ready, reason = wait_for_server_ready(server_process, hash_file, args.server_ready_timeout)
+ print(f"server readiness: {reason}")
+ if not ready:
+ print("server tail:")
+ for line in tail_lines(server_log):
+ print(line)
+ return 90
+
+ if args.settle_seconds > 0:
+ time.sleep(args.settle_seconds)
+
+ client_result = subprocess.run(
+ client_command,
+ env=env,
+ capture_output=True,
+ text=True,
+ cwd=str(REPO_ROOT),
+ )
+ append_output(client_log, client_result.stdout)
+ append_output(client_log, client_result.stderr)
+
+ signal_lines = extract_signal_lines(client_log)
+ if signal_lines:
+ print("client signals:")
+ for line in signal_lines:
+ print(line)
+
+ server_signals = extract_signal_lines(server_log)
+ if server_signals:
+ print("server signals:")
+ for line in server_signals[-6:]:
+ print(line)
+
+ if client_result.returncode != 0:
+ print(f"client failed with exit code {client_result.returncode}")
+ print("client tail:")
+ for line in tail_lines(client_log):
+ print(line)
+ print("server tail:")
+ for line in tail_lines(server_log):
+ print(line)
+ else:
+ print(f"scenario passed with client exit code {client_result.returncode}")
+
+ return client_result.returncode
+ finally:
+ stop_server(server_process)
+
+
+def command_list(_args: argparse.Namespace) -> int:
+ for scenario in SCENARIOS.values():
+ print(f"{scenario.name}: {scenario.description}")
+ print(f" server side: {scenario.server_side}")
+ print(f" client side: {scenario.client_side}")
+ print(f" default work dir: {scenario_work_dir(scenario.name)}")
+ return 0
+
+
+def command_show(args: argparse.Namespace) -> int:
+ scenario = SCENARIOS[args.scenario]
+ work_dir = args.work_dir or scenario_work_dir(args.scenario)
+ print(f"Scenario: {scenario.name}")
+ print(scenario.description)
+ print(f"Work dir: {work_dir}")
+ print()
+ print("Server command:")
+ print(quoted(build_server_command(args)))
+ print()
+ print("Client command:")
+ print(quoted(build_client_command(args)))
+ return 0
+
+
+def command_server(args: argparse.Namespace) -> int:
+ if not args.reticulum_root.exists():
+ raise SystemExit(f"Reticulum root does not exist: {args.reticulum_root}")
+ return run_with_environment(build_server_command(args), args.reticulum_root, args.dry_run)
+
+
+def command_client(args: argparse.Namespace) -> int:
+ if not args.reticulum_root.exists():
+ raise SystemExit(f"Reticulum root does not exist: {args.reticulum_root}")
+ return run_with_environment(build_client_command(args), args.reticulum_root, args.dry_run)
+
+
+def command_run(args: argparse.Namespace) -> int:
+ if not args.reticulum_root.exists():
+ raise SystemExit(f"Reticulum root does not exist: {args.reticulum_root}")
+ return run_scenario(args, args.scenario)
+
+
+def command_run_all(args: argparse.Namespace) -> int:
+ if not args.reticulum_root.exists():
+ raise SystemExit(f"Reticulum root does not exist: {args.reticulum_root}")
+
+ results: list[tuple[str, int]] = []
+ for scenario_name in SCENARIO_SEQUENCE:
+ scenario_args = argparse.Namespace(**vars(args))
+ scenario_args.work_dir = None
+ result = run_scenario(scenario_args, scenario_name)
+ results.append((scenario_name, result))
+
+ print("== summary ==")
+ for scenario_name, result in results:
+ status = "PASS" if result == 0 else f"FAIL ({result})"
+ print(f"{scenario_name}: {status}")
+
+ return 0 if all(result == 0 for _, result in results) else 1
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ list_parser = subparsers.add_parser("list", help="List the named proof-probe scenarios.")
+ list_parser.set_defaults(func=command_list)
+
+ show_parser = subparsers.add_parser("show", help="Print the server and client commands for a scenario.")
+ show_parser.add_argument("scenario", choices=sorted(SCENARIOS))
+ show_parser.add_argument("--work-dir", type=Path, default=None)
+ show_parser.add_argument("--duration", type=float, default=120)
+ show_parser.add_argument("--announce-interval", type=float, default=15)
+ show_parser.add_argument("--path-timeout", type=float, default=60)
+ show_parser.add_argument("--timeout", type=float, default=45)
+ show_parser.add_argument("--payload", default=None)
+ show_parser.add_argument("--target", default=None)
+ show_parser.add_argument("--python", default=sys.executable)
+ show_parser.add_argument("--debug", action="store_true")
+ add_tcp_arguments(show_parser)
+ add_radio_arguments(show_parser)
+ show_parser.set_defaults(func=command_show)
+
+ server_parser = subparsers.add_parser("server", help="Run or print the server side of a scenario.")
+ add_server_arguments(server_parser)
+ server_parser.set_defaults(func=command_server)
+
+ client_parser = subparsers.add_parser("client", help="Run or print the client side of a scenario.")
+ add_client_arguments(client_parser)
+ client_parser.set_defaults(func=command_client)
+
+ run_parser = subparsers.add_parser("run", help="Run a full scenario: start server, then run client, then stop server.")
+ add_orchestrator_arguments(run_parser, include_scenario=True)
+ run_parser.set_defaults(func=command_run)
+
+ run_all_parser = subparsers.add_parser("run-all", help="Run all four named scenarios sequentially.")
+ add_orchestrator_arguments(run_all_parser, include_scenario=False)
+ run_all_parser.set_defaults(func=command_run_all)
+
+ return parser
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+ return args.func(args)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
\ No newline at end of file

Served by rngit 1.5.2 - Generated in 0.52s